merge admin frontend changes
This commit is contained in:
@@ -22,4 +22,8 @@ describe('overseas container fees API', () => {
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/adjust', { expectedVersion: 2, fee, idempotencyKey: 'adjust-1' })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/void', { expectedVersion: 2, reason: '重复费用', idempotencyKey: 'void-1' })
|
||||
})
|
||||
it('preserves a pending financial cancellation without marking the source voided', async () => {
|
||||
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { ...raw, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' } })
|
||||
await expect(voidOverseasContainerFee('81', 2, '重复费用', 'void-1')).resolves.toMatchObject({ sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface OverseasContainerFeeSource {
|
||||
paymentVoucherFileIds: string[]; businessDate: string; currency: 'CNY'; amount: number
|
||||
reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'; sourceStatus: 'ACTIVE' | 'SUPERSEDED' | 'VOIDED'
|
||||
reviewReason: string | null; reviewedAt: string | null
|
||||
voidStatus?: string | null
|
||||
}
|
||||
export interface OverseasContainerFeeSaveRequest {
|
||||
customerId: string; customerName: string | null; sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER'
|
||||
@@ -39,6 +40,7 @@ function normalizeFee(raw: RawResource): OverseasContainerFeeSource {
|
||||
supplierId: text(raw.supplierId), supplierName: text(raw.supplierName), supplierCategory: text(raw.supplierCategory), settlementMode: text(raw.settlementMode), paidAt: text(raw.paidAt), paymentMethod: text(raw.paymentMethod), paymentReferenceNo: text(raw.paymentReferenceNo),
|
||||
paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(String) : [], businessDate: requiredText(raw.businessDate), currency: 'CNY', amount: numberValue(raw.amount),
|
||||
reviewStatus: raw.reviewStatus as OverseasContainerFeeSource['reviewStatus'], sourceStatus: raw.sourceStatus as OverseasContainerFeeSource['sourceStatus'], reviewReason: text(raw.reviewReason), reviewedAt: text(raw.reviewedAt),
|
||||
voidStatus: text(raw.voidStatus),
|
||||
}
|
||||
}
|
||||
export async function listOverseasContainerFees(containerBookingId: string) {
|
||||
|
||||
@@ -37,6 +37,15 @@ describe('尾程付款凭证', () => {
|
||||
mocks.download.mockResolvedValue(new Blob(['image']))
|
||||
})
|
||||
|
||||
it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => {
|
||||
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }])
|
||||
const wrapper = await setup()
|
||||
expect(wrapper.text()).toContain('待财务确认')
|
||||
expect(wrapper.text()).not.toContain('已作废')
|
||||
expect(wrapper.findAll('button').some(button => ['调整', '作废'].includes(button.text()))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('显示上传文件名,允许再次上传替换和移除', async () => {
|
||||
const wrapper = await setup()
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
@@ -68,6 +68,8 @@ const feeItems: Array<{ value: OverseasContainerFeeItem; label: string }> = [
|
||||
]
|
||||
const feeItemLabel = (value: string) => feeItems.find((item) => item.value === value)?.label || value
|
||||
const reviewLabels: Record<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' }
|
||||
const voidLabels: Record<string, string> = { NOT_REQUESTED: '—', PENDING_FINANCE: '待财务确认', FINANCE_ATTENTION: '待财务处理', CONFIRMED: '已作废' }
|
||||
function voidPending(row: OverseasContainerFeeSource) { return ['PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(row.voidStatus || '') }
|
||||
const form = reactive<{ customerId: string; feeItemCode: OverseasContainerFeeItem; direction: OverseasContainerFeeDirection; businessDate: string; amount: number | null; supplierId: string; settlementMode: ContainerFeeSettlementMode; paidAt: string; paymentMethod: ContainerFeePaymentMethod; paymentReferenceNo: string; paymentVoucherFileIds: string[] }>({ customerId: '', feeItemCode: 'PORT_FEE', direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), amount: null, supplierId: '', settlementMode: 'ON_ACCOUNT', paidAt: '', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: '', paymentVoucherFileIds: [] })
|
||||
|
||||
watch(() => form.customerId, () => { uploadSequence++; voucherUploading.value = false; form.paymentVoucherFileIds = [] }, { flush: 'sync' })
|
||||
@@ -155,7 +157,7 @@ async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'RE
|
||||
}
|
||||
async function voidFee(row: OverseasContainerFeeSource) {
|
||||
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
|
||||
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '费用已作废')
|
||||
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态')
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -168,7 +170,7 @@ onMounted(load)
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
|
||||
<LoadErrorState v-else-if="error" title="尾程计费加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-table v-else v-loading="loading" :data="rows" empty-text="当前货柜暂无尾程费用">
|
||||
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
|
||||
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
|
||||
<el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column>
|
||||
<el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column>
|
||||
@@ -177,6 +179,7 @@ onMounted(load)
|
||||
<el-table-column label="付款凭证" min-width="180"><template #default="{ row }"><div v-if="row.paymentVoucherFileIds.length" class="voucher-list"><el-button v-for="(fileId, index) in (row.paymentVoucherFileIds as string[])" :key="fileId" link type="primary" @click="previewVoucher(row.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button></div><span v-else>—</span></template></el-table-column>
|
||||
<el-table-column prop="businessDate" label="业务日期" width="120" />
|
||||
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { containerChannelLabel } from './containerChannelLabel'
|
||||
|
||||
describe('container actual channel label', () => {
|
||||
it('preserves the supplied channel name regardless of booking linkage', () => {
|
||||
expect(containerChannelLabel({ channelName: '纽约海铁', oceanBookingId: '123' })).toBe('纽约海铁')
|
||||
expect(containerChannelLabel({ channelName: '纽约海铁', oceanBookingId: null })).toBe('纽约海铁')
|
||||
})
|
||||
it('does not describe a linked booking as unlinked when the channel is absent', () => {
|
||||
expect(containerChannelLabel({ channelName: null, oceanBookingId: '123' })).toBe('—')
|
||||
expect(containerChannelLabel({ channelName: '', oceanBookingId: '123' })).toBe('—')
|
||||
})
|
||||
it('shows pending linkage only when both channel and booking are absent', () => {
|
||||
expect(containerChannelLabel({ channelName: null, oceanBookingId: null })).toBe('待关联订舱')
|
||||
expect(containerChannelLabel({ channelName: '', oceanBookingId: undefined })).toBe('待关联订舱')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ContainerBookingResource } from './types'
|
||||
|
||||
export function containerChannelLabel(container: Pick<ContainerBookingResource, 'channelName' | 'oceanBookingId'>): string {
|
||||
return container.channelName || (container.oceanBookingId ? '—' : '待关联订舱')
|
||||
}
|
||||
+11
-1
@@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ p
|
||||
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, PalletLabelPrintDialog: { template: '<div />' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
|
||||
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
|
||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' } }))
|
||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '<div />' } }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
|
||||
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() }))
|
||||
vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() }))
|
||||
@@ -44,6 +44,16 @@ function accessibleText(wrapper: { element: Element } | undefined) {
|
||||
}
|
||||
|
||||
describe('ContainerDetailView empty return', () => {
|
||||
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
|
||||
const wrapper = await mountDetail('DOMESTIC')
|
||||
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
|
||||
expect(panel.props('container').id).toBe('9001')
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never)
|
||||
panel.vm.$emit('saved'); await flushPromises()
|
||||
expect(getContainer).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('REASSIGNED / V2')
|
||||
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1')
|
||||
})
|
||||
beforeEach(() => {
|
||||
routeState.query = {}
|
||||
routerPush.mockReset()
|
||||
|
||||
@@ -39,11 +39,11 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain(':sections="[\'pda\']"')
|
||||
})
|
||||
|
||||
it('keeps the confirmed loading list inside the domestic container detail only', () => {
|
||||
it('keeps the container detail entry alongside the standalone manifest', () => {
|
||||
expect(routeSource).toContain("name: 'domestic-container-detail'")
|
||||
expect(routeSource).toContain("name: 'overseas-container-detail'")
|
||||
expect(loadingRouteSource).not.toContain("name: 'loading-lists'")
|
||||
expect(navigationSource).not.toContain("title: '装柜清单'")
|
||||
expect(loadingRouteSource).toContain("name: 'loading-lists'")
|
||||
expect(navigationSource).toContain("title: '装柜清单'")
|
||||
expect(source).toContain('<el-tabs v-model="activeTab"')
|
||||
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
|
||||
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
|
||||
|
||||
@@ -11,7 +11,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
|
||||
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingDetail, type LoadingListMode } from '@/modules/loading-lists'
|
||||
import {
|
||||
confirmContainerOutboundDispatch,
|
||||
@@ -24,6 +24,7 @@ import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.v
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
|
||||
import { containerChannelLabel } from '../containerChannelLabel'
|
||||
import {
|
||||
cancelContainerException,
|
||||
completeContainerClearance,
|
||||
@@ -542,12 +543,13 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog dense v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" class="density-action" data-tone="primary" @click="openOceanBookingLink">关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ detail.channelName || '待关联订舱' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" class="density-action" data-tone="primary" @click="openOceanBookingLink">关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ containerChannelLabel(detail) }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
|
||||
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
|
||||
<h2 id="container-voyage-heading">船名航次信息</h2>
|
||||
<ContainerPortSchedulePanel :container="detail" @saved="load" />
|
||||
<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 :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ permissions: new Set<string>(), push: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => mocks.permissions.has(permission) }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('./ContainersView.vue', () => ({ default: { props: ['workbench'], template: '<div data-testid="workbench">{{ workbench }}</div>' } }))
|
||||
|
||||
function render(stage: 'DOMESTIC' | 'OVERSEAS' = 'DOMESTIC') {
|
||||
return mount(ContainerWorkbenchView, { props: { stage }, global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
}
|
||||
|
||||
describe('container stage entry permissions', () => {
|
||||
beforeEach(() => { mocks.permissions.clear(); mocks.push.mockReset() })
|
||||
it('opens overseas operations for an overseas-only user entering the common menu', () => {
|
||||
mocks.permissions.add('platform:firstleg:terminal-drayage:list')
|
||||
const wrapper = render()
|
||||
expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS')
|
||||
expect(wrapper.text()).not.toContain('国内装柜')
|
||||
})
|
||||
it('preserves a direct overseas visit and allows switching back to domestic', async () => {
|
||||
mocks.permissions.add('platform:firstleg:terminal-drayage:list')
|
||||
mocks.permissions.add('platform:firstleg:container:list')
|
||||
const wrapper = render('OVERSEAS')
|
||||
expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS')
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ name: 'domestic-container-workbench' })
|
||||
})
|
||||
it('does not mount data consumers without either list permission', () => {
|
||||
expect(render().find('[data-testid="workbench"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ContainersView from './ContainersView.vue'
|
||||
|
||||
const props = defineProps<{ stage: 'DOMESTIC' | 'OVERSEAS' }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const canDomestic = computed(() => access.can('platform:firstleg:container:list'))
|
||||
const canOverseas = computed(() => access.can('platform:firstleg:terminal-drayage:list'))
|
||||
const current = computed(() => props.stage === 'DOMESTIC' && !canDomestic.value && canOverseas.value ? 'OVERSEAS' : props.stage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-density">
|
||||
<nav class="density-status-tabs" aria-label="货柜业务阶段">
|
||||
<el-button v-if="canDomestic" :class="{ 'is-active': current === 'DOMESTIC' }" :aria-current="current === 'DOMESTIC' ? 'page' : undefined" @click="router.push({ name: 'domestic-container-workbench' })">📦 国内装柜</el-button>
|
||||
<el-button v-if="canOverseas" :class="{ 'is-active': current === 'OVERSEAS' }" :aria-current="current === 'OVERSEAS' ? 'page' : undefined" @click="router.push({ name: 'overseas-container-workbench' })">🚢 海外清关提柜</el-button>
|
||||
</nav>
|
||||
<ContainersView v-if="canDomestic || canOverseas" :key="current" :workbench="current" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -23,6 +23,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
import { containerChannelLabel } from '../containerChannelLabel'
|
||||
import {
|
||||
getTerminalDrayageEmails,
|
||||
sendTerminalDrayageEmail,
|
||||
@@ -45,6 +46,7 @@ type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabi
|
||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<ContainerWorkbenchRow[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
@@ -66,7 +68,7 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const scope = ref<'ACTIVE' | 'HISTORY'>('ACTIVE')
|
||||
const filters = ref<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
|
||||
const filters = ref<Filters>({ customerId: '', keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '', containerType: '', status: '', sailingRange: [] })
|
||||
const applied = ref<Filters>({ ...filters.value })
|
||||
const detail = ref<ContainerBookingResource | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
@@ -244,7 +246,7 @@ const allColumns: DataTableColumn[] = [
|
||||
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
|
||||
{ key: 'channelGroupCode', label: '计划渠道', minWidth: 140, formatter: (row: ContainerBookingResource) => getSmartLoadingChannelGroup(row.channelGroupCode)?.name || '—' },
|
||||
{ key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => row.channelName || '待关联订舱' },
|
||||
{ key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => containerChannelLabel(row) },
|
||||
{ key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' },
|
||||
{ key: 'pickupAppointmentAt', label: '预计到仓', width: 210, slotName: 'pickupAppointmentAt' },
|
||||
{ key: 'actualPickupAt', label: '实际到仓', width: 165, slotName: 'actualPickupAt' },
|
||||
@@ -736,9 +738,9 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<template #toolbar-start><h1>{{ isDomestic ? '货柜列表' : '清关提柜' }}</h1></template>
|
||||
<template #filters>
|
||||
<div class="container-query-panel integrated-query-group">
|
||||
<div class="container-scope-tabs density-tabs" role="tablist" aria-label="货柜完结状态">
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
|
||||
<div class="container-scope-tabs density-status-tabs" role="tablist" aria-label="货柜完结状态">
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
|
||||
</div>
|
||||
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
|
||||
</div>
|
||||
@@ -822,7 +824,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; scrollbar-width: thin; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
|
||||
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
|
||||
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
|
||||
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
|
||||
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ContainersView from './ContainersView.vue'
|
||||
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
|
||||
</script>
|
||||
|
||||
<template><ContainersView workbench="DOMESTIC" /></template>
|
||||
<template><ContainerWorkbenchView stage="DOMESTIC" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ContainersView from './ContainersView.vue'
|
||||
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
|
||||
</script>
|
||||
|
||||
<template><ContainersView workbench="OVERSEAS" /></template>
|
||||
<template><ContainerWorkbenchView stage="OVERSEAS" /></template>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const containerLoadingRoutes = [
|
||||
path: 'container-loading/domestic',
|
||||
name: 'domestic-container-workbench',
|
||||
component: () => import('./pages/DomesticContainersView.vue'),
|
||||
meta: { showPageHeading: false, title: '货柜列表', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' },
|
||||
meta: { showPageHeading: false, title: '货柜列表', description: '管理国内装柜、海外清关提柜与到仓', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
|
||||
},
|
||||
{
|
||||
path: 'container-loading/domestic/:containerId',
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ContainerBookingResource {
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
oceanBookingId?: string | null
|
||||
currentPortScheduleId?: string | null
|
||||
oceanBookingRequirementId?: string | null
|
||||
customerId?: string | null
|
||||
customerName?: string | null
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DashboardView from './DashboardView.vue'
|
||||
import { getTodayNewOrderCount } from '../api/dashboard'
|
||||
import { getTodayNewOrderCount, getActiveCustomerCount, getDashboardOperationalMetrics } from '../api/dashboard'
|
||||
|
||||
const access = vi.hoisted(() => ({ permissions: new Set<string>() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) }))
|
||||
|
||||
vi.mock('../api/dashboard', () => ({
|
||||
getTodayNewOrderCount: vi.fn().mockResolvedValue(12),
|
||||
@@ -18,7 +21,28 @@ vi.mock('../api/dashboard', () => ({
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('DashboardView', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
access.permissions = new Set(['platform:order:list', 'platform:customer:list', 'platform:firstleg:container:list', 'platform:firstleg:customs:list'])
|
||||
})
|
||||
|
||||
it('makes no metric requests for a minimal pool sales account and gives an authorized-menu hint', async () => {
|
||||
access.permissions = new Set(['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:capacity-offer:inquiry-edit', 'platform:fleet:capacity-offer:inquiry-cancel'])
|
||||
const wrapper = mount(DashboardView); await flushPromises()
|
||||
expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getActiveCustomerCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('请从左侧菜单进入已授权业务')
|
||||
expect(wrapper.text()).not.toContain('指标加载失败'); expect(wrapper.text()).not.toContain('刷新数据')
|
||||
})
|
||||
|
||||
it('requests only granted metric sources and requires both container and customs permissions', async () => {
|
||||
access.permissions = new Set(['platform:customer:list', 'platform:firstleg:container:list'])
|
||||
const wrapper = mount(DashboardView); await flushPromises()
|
||||
expect(getActiveCustomerCount).toHaveBeenCalledOnce(); expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.dashboard-metric p').map(node => node.text())).toEqual(['活跃客户'])
|
||||
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click'); await flushPromises()
|
||||
expect(getActiveCustomerCount).toHaveBeenCalledTimes(2); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders only the four live metrics', async () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RefreshCw,
|
||||
} from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import {
|
||||
getActiveCustomerCount,
|
||||
getDashboardOperationalMetrics,
|
||||
@@ -22,6 +23,11 @@ const todayNewOrderCount = ref<number | null>(null)
|
||||
const activeCustomerCount = ref<number | null>(null)
|
||||
const operationalMetrics = ref<DashboardOperationalMetrics | null>(null)
|
||||
const metricsUpdatedAt = ref<string | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
const canReadOrders = computed(() => access.can('platform:order:list'))
|
||||
const canReadCustomers = computed(() => access.can('platform:customer:list'))
|
||||
const canReadOperations = computed(() => access.can('platform:firstleg:container:list') && access.can('platform:firstleg:customs:list'))
|
||||
const hasMetrics = computed(() => canReadOrders.value || canReadCustomers.value || canReadOperations.value)
|
||||
|
||||
function displayNumber(value: number | null | undefined, maximumFractionDigits = 0) {
|
||||
if (metricsLoading.value) return '…'
|
||||
@@ -30,17 +36,18 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits =
|
||||
}
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' },
|
||||
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue', visible: canReadOrders.value },
|
||||
{
|
||||
label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU',
|
||||
note: operationalMetrics.value
|
||||
? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}`
|
||||
: '已开船且尚未到仓',
|
||||
tone: 'green',
|
||||
visible: canReadOperations.value,
|
||||
},
|
||||
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple' },
|
||||
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' },
|
||||
])
|
||||
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple', visible: canReadCustomers.value },
|
||||
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose', visible: canReadOperations.value },
|
||||
].filter(metric => metric.visible))
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen()
|
||||
@@ -55,17 +62,17 @@ async function loadMetrics() {
|
||||
metricsLoading.value = true
|
||||
metricsError.value = false
|
||||
const [orders, customers, operations] = await Promise.allSettled([
|
||||
getTodayNewOrderCount(),
|
||||
getActiveCustomerCount(),
|
||||
getDashboardOperationalMetrics(),
|
||||
canReadOrders.value ? getTodayNewOrderCount() : Promise.resolve(null),
|
||||
canReadCustomers.value ? getActiveCustomerCount() : Promise.resolve(null),
|
||||
canReadOperations.value ? getDashboardOperationalMetrics() : Promise.resolve(null),
|
||||
])
|
||||
todayNewOrderCount.value = orders.status === 'fulfilled' ? orders.value : null
|
||||
activeCustomerCount.value = customers.status === 'fulfilled' ? customers.value : null
|
||||
operationalMetrics.value = operations.status === 'fulfilled' ? operations.value : null
|
||||
metricsError.value = [orders, customers, operations].some(result => result.status === 'rejected')
|
||||
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value.asOf
|
||||
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value?.asOf
|
||||
? operations.value.asOf
|
||||
: dayjs().format('YYYY-MM-DDTHH:mm:ss')
|
||||
: hasMetrics.value ? dayjs().format('YYYY-MM-DDTHH:mm:ss') : null
|
||||
metricsLoading.value = false
|
||||
}
|
||||
|
||||
@@ -80,10 +87,10 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
<section ref="dashboardRef" class="dashboard-view">
|
||||
<AdminPage as="div" spacing="normal">
|
||||
<section class="dashboard-context" aria-label="数据看板说明">
|
||||
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
||||
<p>{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
<span v-if="hasMetrics">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
<RefreshCw :size="16" aria-hidden="true" />
|
||||
刷新数据
|
||||
</button>
|
||||
@@ -95,7 +102,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-metrics" aria-label="核心业务指标">
|
||||
<div v-if="hasMetrics" class="dashboard-metrics" aria-label="核心业务指标">
|
||||
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
|
||||
<span class="dashboard-metric-mark" aria-hidden="true" />
|
||||
<p>{{ metric.label }}</p>
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('DomesticContainersView', () => {
|
||||
it('gates create actions with backend permissions', () => { expect(source).toContain("access.can('platform:domestic-container:inbound:create')"); expect(source).toContain("access.can('platform:domestic-container:outbound:create')") })
|
||||
it('loads only active domestic warehouse candidates and surfaces errors', () => { expect(source).toContain("listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })"); expect(source).toContain('国内仓候选加载失败') })
|
||||
it('keeps idempotency key stable through retry and handles conflicts by refreshing inventory', () => { expect(source).toContain('idempotencyKey.value'); expect(source).toContain("submitError.value.code === 'A0409'"); expect(source).toContain('await loadCandidates()'); expect(source).not.toContain('localStorage') })
|
||||
it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel integrated-query-group"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain(".status-tab.is-active::after{background:var(--cc-primary)}"); expect(source).not.toContain('<el-tabs') })
|
||||
it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel integrated-query-group"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain('class="status-tabs density-status-tabs"'); expect(source).not.toContain('<el-tabs') })
|
||||
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
|
||||
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
|
||||
it('keeps all four inventory facts in the approved compact summary', () => {
|
||||
|
||||
@@ -76,10 +76,10 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
|
||||
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
|
||||
<template #toolbar-start><h1>整柜业务</h1></template>
|
||||
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
|
||||
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
|
||||
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid var(--admin-table-border, #e5e7eb);background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;display:inline-flex;align-items:center;gap:var(--admin-space-2, 6px);min-height:var(--admin-tab-height, 38px);padding:0 var(--admin-space-6, 16px);border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:var(--admin-font-body, 13px);cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
acceptPlatformFirstLegOrder,
|
||||
approvePlatformFirstLegOrder,
|
||||
cancelPlatformFirstLegOrder,
|
||||
completePlatformFirstLegOrder,
|
||||
getPlatformFirstLegOrder,
|
||||
listPlatformFirstLegOrders,
|
||||
startPlatformFirstLegOrder,
|
||||
@@ -82,15 +81,14 @@ describe('platform first-leg order API', () => {
|
||||
await approvePlatformFirstLegOrder('10001', '90001')
|
||||
await acceptPlatformFirstLegOrder('10001', '90001')
|
||||
await startPlatformFirstLegOrder('10001', '90001')
|
||||
await completePlatformFirstLegOrder('10001', '90001')
|
||||
await cancelPlatformFirstLegOrder('10001', '90001', '客户取消')
|
||||
await cancelPlatformFirstLegOrder('10001', '90001', { expectedVersion: 7, reason: '客户取消' }, 'cancel-original-key')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001', { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/orders/first-leg/90001/accept', undefined, { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/orders/first-leg/90001/start', undefined, { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/orders/first-leg/90001/complete', undefined, { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(5, '/platform/v1/orders/first-leg/90001/cancel', { reason: '客户取消' }, { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/orders/first-leg/90001/cancel', { expectedVersion: 7, reason: '客户取消' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'cancel-original-key' } })
|
||||
expect(http.post).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('writes the backend express contract as names, not a guessed master-data ID', async () => {
|
||||
|
||||
@@ -184,12 +184,17 @@ export function startPlatformFirstLegOrder(tenantId: string, orderId: string) {
|
||||
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/start`, undefined, { tenantId })
|
||||
}
|
||||
|
||||
export function completePlatformFirstLegOrder(tenantId: string, orderId: string) {
|
||||
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/complete`, undefined, { tenantId })
|
||||
}
|
||||
|
||||
export function cancelPlatformFirstLegOrder(tenantId: string, orderId: string, reason: string) {
|
||||
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/cancel`, { reason }, { tenantId })
|
||||
export async function cancelPlatformFirstLegOrder(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
payload: { expectedVersion: number; reason: string },
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<void>, ApiEnvelope<void>>(
|
||||
`/platform/v1/orders/first-leg/${orderId}/cancel`, payload,
|
||||
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function updatePlatformFirstLegOrderExpress(
|
||||
|
||||
@@ -102,7 +102,8 @@ export function buildNavigationPermissionTree(
|
||||
}
|
||||
|
||||
function navigationNode(node: AdminNavigationNode): RolePermissionTreeNode | null {
|
||||
if (node.hidden) return null
|
||||
// Hiding a formal sidebar entry must not remove its existing assignable permissions.
|
||||
if (node.hidden && !node.permission && !node.permissionAnyOf?.length) return null
|
||||
|
||||
if (node.children?.length) {
|
||||
const children = node.children
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LoadingListsView from './LoadingListsView.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn(), can: vi.fn(), push: vi.fn(), replace: vi.fn(), query: {} as Record<string, string> }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push, replace: mocks.replace }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) }))
|
||||
vi.mock('@/modules/container-loading', () => ({ ContainerSimulationCreate: { template: '<div />' } }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: async () => ({ records: [] }) }))
|
||||
vi.mock('../api/platformLoadingLists', () => ({ listConfirmedLoadingLists: mocks.list, listPreloadPlans: mocks.list, getConfirmedLoadingList: mocks.get }))
|
||||
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
|
||||
vi.mock('./LoadingListDetailDrawer.vue', () => ({ default: { props: ['detail', 'error'], template: '<div data-testid="detail">{{ detail?.summary.loadingListNo }}{{ error?.message }}</div>' } }))
|
||||
|
||||
const rows = [{ planId: '1', warehouseId: '11' }, { planId: '2', warehouseId: '22' }]
|
||||
function render() {
|
||||
return mount(LoadingListsView, { props: { mode: 'confirmed' }, global: { stubs: {
|
||||
AdminPage: { template: '<div><slot /></div>' },
|
||||
ListPageLayout: { template: '<div><slot /></div>' },
|
||||
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.planId"><slot name="actions" :row="row" /></div></div>' },
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
} } })
|
||||
}
|
||||
describe('confirmed manifest navigation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.query = {}
|
||||
mocks.can.mockReturnValue(true)
|
||||
mocks.list.mockResolvedValue({ records: rows, total: 2 })
|
||||
})
|
||||
it('opens each manifest on its dedicated detail route with warehouse context', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||
await wrapper.findAll('button')[2]!.trigger('click')
|
||||
expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } })
|
||||
expect(mocks.get).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-testid="detail"]').exists()).toBe(false)
|
||||
})
|
||||
it('prints through the dedicated detail page', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button')[1]!.trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11', print: '1' } })
|
||||
expect(mocks.get).not.toHaveBeenCalled()
|
||||
})
|
||||
it('replaces a list deep link with the dedicated detail route', async () => {
|
||||
mocks.query = { planId: '2', warehouseId: '22' }
|
||||
render(); await flushPromises()
|
||||
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } })
|
||||
})
|
||||
it('does not fetch manifests without list permission', async () => {
|
||||
mocks.can.mockReturnValue(false)
|
||||
render(); await flushPromises()
|
||||
expect(mocks.list).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -66,8 +66,8 @@ describe('LoadingListsView', () => {
|
||||
expect(confirmedDetailSource).toContain('返回列表')
|
||||
expect(confirmedDetailSource).toContain('router.back()')
|
||||
expect(confirmedDetailSource).toContain('@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }')
|
||||
expect(routesSource).not.toContain("path: 'loading-lists/:planId'")
|
||||
expect(routesSource).not.toContain("name: 'loading-list-detail'")
|
||||
expect(routesSource).toContain("path: 'loading-lists/:planId'")
|
||||
expect(routesSource).toContain("name: 'loading-list-detail'")
|
||||
})
|
||||
|
||||
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
|
||||
@@ -111,7 +111,7 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain('useRoute()')
|
||||
expect(source).toContain('route.query.planId')
|
||||
expect(source).toContain('route.query.warehouseId')
|
||||
expect(source).toContain("router.replace({ name: 'loading-list-detail'")
|
||||
expect(source).toContain("await router.replace({ name: 'loading-list-detail'")
|
||||
expect(confirmedDetailSource).toContain("result.summary.status !== 'COMPLETED'")
|
||||
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
|
||||
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
|
||||
@@ -139,7 +139,7 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain(":class=\"{ 'density-action': isPreload }\"")
|
||||
expect(source).toContain(':type="isPreload ? undefined : \'primary\'" :plain="!isPreload"')
|
||||
expect(source).not.toContain('<el-button v-if="canView" link')
|
||||
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 110")
|
||||
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 150")
|
||||
expect(source).toContain('flex-wrap: wrap')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source).toContain('.row-actions { gap: 4px; }')
|
||||
|
||||
@@ -40,6 +40,9 @@ const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
|
||||
const handledDeepLink = ref('')
|
||||
function printConfirmed(row: LoadingListSummary) {
|
||||
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || ''), print: '1' } })
|
||||
}
|
||||
|
||||
const isPreload = computed(() => props.mode === 'preload')
|
||||
const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
|
||||
@@ -69,7 +72,7 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
|
||||
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 90, align: 'right', slotName: 'grossWeight' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 140, slotName: 'createdAt' },
|
||||
] : [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 150, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
|
||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
|
||||
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
|
||||
@@ -223,6 +226,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
|
||||
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">查看</el-button>
|
||||
<el-button v-if="canView && !isPreload" class="density-action" size="small" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
|
||||
</div></template>
|
||||
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
|
||||
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import ConfirmedLoadingListDetailView from './ConfirmedLoadingListDetailView.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => mocks }))
|
||||
vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get }))
|
||||
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
|
||||
|
||||
function render() {
|
||||
return shallowMount(ConfirmedLoadingListDetailView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<section />' },
|
||||
} } })
|
||||
}
|
||||
const completed = (planId = '1') => ({ summary: { status: 'COMPLETED', planId, loadingListNo: `LIST-${planId}` }, items: [], histories: [] })
|
||||
|
||||
describe('confirmed loading list detail page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.route = reactive({ params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||
mocks.get.mockResolvedValue(completed())
|
||||
vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
it('loads read-only detail on the dedicated page and returns to the list', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(mocks.get).toHaveBeenCalledWith('1', '11')
|
||||
const detail = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
|
||||
expect(detail.props('detail')).toEqual(completed())
|
||||
expect(window.print).not.toHaveBeenCalled()
|
||||
vi.spyOn(window.history, 'length', 'get').mockReturnValue(1)
|
||||
detail.vm.$emit('back')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-lists' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('prints only after detail has loaded and removes the print request', async () => {
|
||||
mocks.route.query.print = '1'
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(window.print).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false)
|
||||
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps failure visible and does not print when loading fails', async () => {
|
||||
mocks.route.query.print = '1'
|
||||
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error').message).toBe('清单不可用')
|
||||
expect(window.print).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a stale response after switching manifests', async () => {
|
||||
let resolveFirst!: (value: unknown) => void
|
||||
mocks.get.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
|
||||
.mockResolvedValueOnce(completed('2'))
|
||||
const wrapper = render()
|
||||
mocks.route.params.planId = '2'
|
||||
await flushPromises()
|
||||
resolveFirst(completed()); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('detail')).toEqual(completed('2'))
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -37,6 +37,14 @@ async function loadDetail() {
|
||||
return
|
||||
}
|
||||
detail.value = result
|
||||
loading.value = false
|
||||
if (route.query.print === '1') {
|
||||
await nextTick()
|
||||
if (sequence !== loadSequence) return
|
||||
window.print()
|
||||
const { print: _print, ...query } = route.query
|
||||
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
|
||||
}
|
||||
} catch (cause) {
|
||||
if (sequence !== loadSequence) return
|
||||
detail.value = null
|
||||
@@ -51,6 +59,10 @@ function goBack() {
|
||||
else void router.push({ name: 'loading-lists' })
|
||||
}
|
||||
|
||||
function printDetail() {
|
||||
window.print()
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
watch([planId, warehouseId], () => void loadDetail())
|
||||
</script>
|
||||
@@ -58,6 +70,7 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="confirmed-loading-detail-page">
|
||||
<el-button v-if="detail" class="print-action" :disabled="loading" @click="printDetail">打印</el-button>
|
||||
<LoadingListDetailDrawer
|
||||
mode="confirmed"
|
||||
:loading="loading"
|
||||
@@ -73,5 +86,6 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
|
||||
<style scoped>
|
||||
.confirmed-loading-detail-page { display: grid; min-width: 0; max-width: 1280px; gap: 16px; }
|
||||
@media print { .print-action { display: none; } }
|
||||
@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import { loadingListRoutes } from './routes'
|
||||
|
||||
describe('loading list routes', () => {
|
||||
it('keeps completed loading lists inside domestic container detail', () => {
|
||||
it('registers the confirmed list with its own read permission', () => {
|
||||
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
|
||||
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
|
||||
['loading-lists', 'platform:firstleg:loading-list:list'],
|
||||
['loading-plans', 'platform:firstleg:preload:list'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
||||
])
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const loadingListRoutes = [
|
||||
{
|
||||
path: 'loading-lists/:planId',
|
||||
name: 'loading-list-detail',
|
||||
component: () => import('./pages/ConfirmedLoadingListDetailView.vue'),
|
||||
meta: { title: '装柜清单详情', showPageHeading: false, permission: 'platform:firstleg:loading-list:list' },
|
||||
},
|
||||
{
|
||||
path: 'loading-lists',
|
||||
name: 'loading-lists',
|
||||
component: () => import('./pages/ConfirmedLoadingListsView.vue'),
|
||||
meta: { title: '装柜清单', showPageHeading: false, permission: 'platform:firstleg:loading-list:list' },
|
||||
},
|
||||
{
|
||||
path: 'loading-plans',
|
||||
name: 'loading-plans',
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# 海外业务原型核对矩阵
|
||||
|
||||
## 池来源本土订单承运安排
|
||||
|
||||
依据:[内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7节及本轮用户授权。
|
||||
|
||||
| 页面 | 操作 | 确认承载与实现 |
|
||||
| --- | --- | --- |
|
||||
| 本土卡车 / 本土散板列表 | 池来源承运安排 | 1080px居中只读摘要,使用列表已有fleetArrangement,打开不增加请求;不显示旧编辑动作 |
|
||||
| 本土卡车 / 本土散板旧编辑深链 | 池来源只读 | 保持独立路由,读取原订单详情后仅展示真实安排与只读原因,不提交旧手工流程 |
|
||||
| 承运安排 | 关联单据 | 有目标权限才开放车次/排车跳转,否则仅显示业务号;未知签收数量显示“—” |
|
||||
| Client本土订单 | 列表摘要 / 原独立详情 | 同一客户订单响应展示承运安排,客户不访问运力池,不提供状态写入 |
|
||||
|
||||
独立来源订单保持原承载。本轮视觉按1920×1080内置浏览器验收,自动检查与实际页面证据分别报告。
|
||||
|
||||
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/07-overseas-business.html`;按钮函数来源:同原型目录 `prototype-src/app.js`。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 |
|
||||
|
||||
@@ -107,4 +107,12 @@ describe('platform overseas business API adapter', () => {
|
||||
expect(overseas.post).not.toHaveBeenCalled()
|
||||
expect(overseas.put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves unknown pool pallet service and container type in list and detail responses', async () => {
|
||||
const summary = { ...looseboardSummary, palletRequired: null, containerType: null }
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] }))
|
||||
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, containerType: null })
|
||||
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, containerType: null })
|
||||
expect(overseas.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { mount, enableAutoUnmount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { compileStyle, parse } from 'vue/compiler-sfc'
|
||||
import FleetArrangementSummary from './FleetArrangementSummary.vue'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
|
||||
const state = vi.hoisted(() => ({ writableLinks: true, push: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.writableLinks }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }) }))
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => { state.writableLinks = true; state.push.mockClear() })
|
||||
const arrangement: FleetArrangement = {
|
||||
sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'LOCAL_LOOSEBOARD', businessOrderNo: 'LB-ONT-20261007-309123', bookingNo: 'FB-2107769452587171841', tripNo: 'TR-76DB64B4457C4206',
|
||||
plannedDepartureAt: '2026-10-07T13:46:07Z', originWarehouseName: '洛杉矶仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: '交货说明完整保留', fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null,
|
||||
agreedFreightAmount: 30, currency: 'CNY', incomingNo: 'WT-ONT-20261007-12345678901234567890', dispatchNo: 'DP-ONT-20261007-12345678901234567890', deliveryNo: 'DL-ONT-20261007-12345678901234567890',
|
||||
signedPackages: 0, unsignedPackages: null, returnedPackages: 0, exceptionSummary: null, readOnlyReason: '此订单由拼车承运实际履约推进',
|
||||
}
|
||||
function render() { return mount(FleetArrangementSummary, { props: { arrangement }, global: { plugins: [ElementPlus] } }) }
|
||||
it('retains all long business numbers, labels and authorized navigation', async () => {
|
||||
const wrapper = render()
|
||||
const rows = wrapper.findAll('.fleet-arrangement-fields > div')
|
||||
expect(rows).toHaveLength(14)
|
||||
for (const [label, value] of [['订单号', arrangement.businessOrderNo], ['外接预订号', arrangement.bookingNo], ['车次号', arrangement.tripNo], ['来货单号', arrangement.incomingNo], ['排车单号', arrangement.dispatchNo], ['派送单号', arrangement.deliveryNo]]) {
|
||||
expect(rows.find(row => row.get('dt').text() === label)!.get('dd').text()).toBe(value)
|
||||
}
|
||||
expect(rows.find(row => row.get('dt').text() === '已签 / 未签 / 退回箱数')!.get('dd').text()).toBe('0 / — / 0')
|
||||
const links = wrapper.findAll('button.fleet-arrangement-reference')
|
||||
expect(links.map(link => link.text())).toEqual([arrangement.tripNo, arrangement.dispatchNo])
|
||||
await links[0]!.trigger('click'); await links[1]!.trigger('click')
|
||||
expect(state.push).toHaveBeenNthCalledWith(1, { name: 'overseas-master-data-trip-detail', params: { tripNo: arrangement.tripNo } })
|
||||
expect(state.push).toHaveBeenNthCalledWith(2, { name: 'overseas-dispatch-detail', params: { dispatchNo: arrangement.dispatchNo } })
|
||||
})
|
||||
it('keeps full reference text without links when target permissions are absent', () => {
|
||||
state.writableLinks = false
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain(arrangement.tripNo); expect(wrapper.text()).toContain(arrangement.dispatchNo)
|
||||
})
|
||||
it('uses a scoped two-column layout and wrap-capable reference controls rather than truncation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-business/components/FleetArrangementSummary.vue'), 'utf8')
|
||||
const style = parse(source).descriptor.styles[0]!
|
||||
expect(style.scoped).toBe(true)
|
||||
const compiled = compileStyle({ source: style.content, filename: 'FleetArrangementSummary.vue', id: 'data-v-arrangement', scoped: true })
|
||||
expect(compiled.errors).toEqual([])
|
||||
const rules = new Map<string, Record<string, string>>()
|
||||
compiled.rawResult!.root.walkRules(rule => {
|
||||
const declarations: Record<string, string> = {}
|
||||
rule.walkDecls(declaration => { declarations[declaration.prop] = declaration.value })
|
||||
rules.set(rule.selector, declarations)
|
||||
})
|
||||
const grid = [...rules].find(([selector]) => selector.includes('.fleet-arrangement-fields.density-fields'))!
|
||||
expect(grid[0]).toContain('[data-v-arrangement]')
|
||||
expect(grid[1]).toMatchObject({ 'grid-template-columns': 'repeat(2, minmax(0, 1fr))', '--admin-field-label-width': '160px' })
|
||||
const button = [...rules].find(([selector]) => selector.endsWith('.fleet-arrangement-reference[data-v-arrangement]'))!
|
||||
expect(button[1]).toMatchObject({ 'max-width': '100%', 'min-width': '0', height: 'auto', 'white-space': 'normal', 'text-align': 'left' })
|
||||
const text = [...rules].find(([selector]) => selector.endsWith(' span'))!
|
||||
expect(text[1]).toMatchObject({ 'white-space': 'normal', 'overflow-wrap': 'anywhere', 'min-width': '0' })
|
||||
expect(style.content).not.toMatch(/text-overflow:\s*ellipsis|overflow:\s*hidden|white-space:\s*nowrap/)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
|
||||
defineProps<{ arrangement: FleetArrangement }>()
|
||||
const access = useAccessPreviewStore(), router = useRouter()
|
||||
const time = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="承运安排">
|
||||
<p>{{ arrangement.readOnlyReason }}</p>
|
||||
<dl class="density-fields fleet-arrangement-fields">
|
||||
<div><dt>订单号</dt><dd>{{ arrangement.businessOrderNo }}</dd></div>
|
||||
<div><dt>外接预订号</dt><dd>{{ arrangement.bookingNo }}</dd></div>
|
||||
<div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" class="fleet-arrangement-reference" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: arrangement.tripNo } })">{{ arrangement.tripNo }}</el-button><span v-else>{{ arrangement.tripNo }}</span></dd></div>
|
||||
<div><dt>计划发车</dt><dd>{{ time(arrangement.plannedDepartureAt) }}</dd></div>
|
||||
<div><dt>起运仓</dt><dd>{{ arrangement.originWarehouseName || '—' }}</dd></div>
|
||||
<div><dt>目的地</dt><dd>{{ arrangement.destinationLabel || '—' }}</dd></div>
|
||||
<div><dt>交货方式</dt><dd>{{ arrangement.deliveryMode === 'SELF_DELIVERY' ? '客户自送我仓' : '线下提货回我仓' }}</dd></div>
|
||||
<div><dt>履约阶段</dt><dd>{{ poolOrderStatusLabels[arrangement.fulfillmentStage] || '待核对履约进度' }}</dd></div>
|
||||
<div><dt>阶段时间</dt><dd>{{ time(arrangement.stageOccurredAt) }}</dd></div>
|
||||
<div><dt>约定运费(非实收)</dt><dd>{{ arrangement.agreedFreightAmount == null ? '—' : `${Number(arrangement.agreedFreightAmount).toFixed(2)} ${arrangement.currency || ''}` }}</dd></div>
|
||||
<div><dt>来货单号</dt><dd>{{ arrangement.incomingNo || '—' }}</dd></div>
|
||||
<div><dt>排车单号</dt><dd><el-button v-if="arrangement.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" class="fleet-arrangement-reference" link type="primary" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: arrangement.dispatchNo } })">{{ arrangement.dispatchNo }}</el-button><span v-else>{{ arrangement.dispatchNo || '—' }}</span></dd></div>
|
||||
<div><dt>派送单号</dt><dd>{{ arrangement.deliveryNo || '—' }}</dd></div>
|
||||
<div><dt>已签 / 未签 / 退回箱数</dt><dd>{{ arrangement.signedPackages ?? '—' }} / {{ arrangement.unsignedPackages ?? '—' }} / {{ arrangement.returnedPackages ?? '—' }}</dd></div>
|
||||
</dl>
|
||||
<p v-if="arrangement.deliveryInstructions">交货说明:{{ arrangement.deliveryInstructions }}</p>
|
||||
<p v-if="arrangement.exceptionSummary" role="status">履约说明:{{ arrangement.exceptionSummary }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fleet-arrangement-fields.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); --admin-field-label-width: 160px; }
|
||||
.fleet-arrangement-fields dt { overflow-wrap: anywhere; }
|
||||
.fleet-arrangement-fields .fleet-arrangement-reference { max-width: 100%; min-width: 0; height: auto; justify-content: flex-start; white-space: normal; text-align: left; line-height: inherit; }
|
||||
.fleet-arrangement-reference :deep(span) { display: block; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface FleetArrangement {
|
||||
sourceType: 'FLEET_CAPACITY_OFFER'
|
||||
businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
|
||||
businessOrderNo: string
|
||||
bookingNo: string
|
||||
tripNo: string
|
||||
plannedDepartureAt: string | null
|
||||
originWarehouseName: string
|
||||
destinationLabel: string
|
||||
deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE'
|
||||
deliveryInstructions: string | null
|
||||
fulfillmentStage: string
|
||||
stageOccurredAt: string | null
|
||||
agreedFreightAmount: number | null
|
||||
currency: string | null
|
||||
incomingNo: string | null
|
||||
dispatchNo: string | null
|
||||
deliveryNo: string | null
|
||||
signedPackages: number | null
|
||||
unsignedPackages: number | null
|
||||
returnedPackages: number | null
|
||||
exceptionSummary: string | null
|
||||
readOnlyReason: string
|
||||
}
|
||||
|
||||
export const poolOrderStatusLabels: Record<string, string> = {
|
||||
WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
|
||||
PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消',
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
|
||||
import {
|
||||
createOverseasPurchaseFee, uploadOverseasPurchaseFile, createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
|
||||
@@ -44,10 +45,11 @@ const purchase: OverseasPurchaseDetail = {
|
||||
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
|
||||
}
|
||||
const truck: LocalTransportOrder = { id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
|
||||
const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
|
||||
|
||||
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
|
||||
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
|
||||
@@ -65,6 +67,49 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
|
||||
|
||||
it('opens pool arrangements from the list without another request or exposing edit actions', async () => {
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
expect(wrapper.findAll('.el-table__body-wrapper button').some(b => b.text() === '编辑')).toBe(false)
|
||||
await wrapper.findAll('.el-table__body-wrapper button').find(b => b.text() === '承运安排')!.trigger('click'); await flushPromises()
|
||||
expect(document.body.textContent).toContain('池来源订单按实际运输履约推进')
|
||||
expect(document.body.textContent).toContain('FB-TEST'); expect(document.body.textContent).toContain('— / — / —')
|
||||
expect(listUsTruckOrders).toHaveBeenCalledOnce(); expect(getUsTruckOrder).not.toHaveBeenCalled()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
await dialog.findAll('.app-dialog__footer button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
it('keeps a pool order read-only even when an old edit deep link is used', async () => {
|
||||
routing.route.params = { orderId: 'TR-200' }; routing.route.query = { customerId: truck.customerId }
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue({ ...truckDetail, fleetArrangement: arrangement, status: 'WAITING_INBOUND' })
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('池来源订单按实际运输履约推进')
|
||||
expect(wrapper.find('form').exists()).toBe(false); expect(wrapper.findAll('button').some(b => b.text() === '保存修改')).toBe(false)
|
||||
expect(updateUsTruckOrder).not.toHaveBeenCalled(); expect(getUsTruckOrder).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('distinguishes unknown pool pallet service from explicit no and yes in actual list cells', async () => {
|
||||
const records = ([null, 'NO', 'YES'] as const).map((palletRequired, index) => ({ ...looseboard, id: String(100 + index), orderNo: `LB-NULL-${index}`, palletRequired, containerType: null, fleetArrangement: { ...arrangement, businessType: 'LOCAL_LOOSEBOARD' as const } }))
|
||||
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records, total: 3, page: 1, size: 20, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises()
|
||||
const rows = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')
|
||||
expect(rows.map(row => row.findAll('td')[3]!.text())).toEqual(['—', '否', '是'])
|
||||
expect(listLocalLooseboardOrders).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('shows an unknown pool container type as a dash without inventing 40HQ', async () => {
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, containerType: null, fleetArrangement: arrangement }], total: 1, page: 1, size: 20, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[7]!.text()).toBe('—')
|
||||
expect(wrapper.find('.el-table__body-wrapper').text()).not.toContain('40HQ')
|
||||
})
|
||||
it('still rejects an ordinary looseboard edit when its required pallet service is absent', async () => {
|
||||
routing.route.params = { orderId: looseboard.orderNo }; routing.route.query = { customerId: looseboard.customerId }
|
||||
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, fleetArrangement: null })
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'ElForm' }).props('model').palletRequired).toBeNull()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('保存修改'))!.trigger('click'); await flushPromises()
|
||||
expect(updateLocalLooseboardOrder).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('请选择是否打盘')
|
||||
})
|
||||
it('keeps all procurement facts in dense groups and business references in the heading', async () => {
|
||||
routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' }
|
||||
const wrapper = mountPage(PurchaseOrderDetailView)
|
||||
|
||||
@@ -17,6 +17,8 @@ import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '@/shared/domai
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
@@ -37,6 +39,7 @@ interface TransportFormModel {
|
||||
function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
|
||||
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
|
||||
const fleetArrangement = ref<FleetArrangement | null>(null)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
|
||||
const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' })
|
||||
@@ -54,9 +57,9 @@ const rules: FormRules<TransportFormModel> = {
|
||||
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
|
||||
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus])
|
||||
const editableStatus = computed(() => !editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW'))
|
||||
const editableStatus = computed(() => !fleetArrangement.value && (!editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW')))
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
function amount(value: string | null) { return value === null || value === '' ? null : Number(value) }
|
||||
@@ -68,16 +71,18 @@ async function loadCustomers() {
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
async function load() {
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null; fleetArrangement.value = null
|
||||
if (!editing.value) { captureBaseline(); return }
|
||||
if (!routeCustomerId.value) { loadError.value = { message: `缺少客户标识,请从${domainLabel.value}列表重新进入` }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
if (isLooseboard.value) {
|
||||
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
} else {
|
||||
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
}
|
||||
captureBaseline()
|
||||
@@ -86,6 +91,7 @@ async function load() {
|
||||
async function save() {
|
||||
if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return }
|
||||
if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return }
|
||||
if (isLooseboard.value && form.palletRequired !== 'YES' && form.palletRequired !== 'NO') { submitError.value = { message: '请选择是否打盘' }; return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
const common = { transportType: form.transportType as LocalTransportType, origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), containerType: optional(form.containerType), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, contactName: optional(form.contactName), contactInfo: optional(form.contactInfo), requestedDate: form.requestedDate, remark: optional(form.remark) }
|
||||
saving.value = true; submitError.value = null
|
||||
@@ -110,6 +116,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
|
||||
<AdminPage class="admin-density overseas-business-editor"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
|
||||
<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">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
|
||||
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
|
||||
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
|
||||
<FormSection class="density-form-section" v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
|
||||
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
|
||||
|
||||
@@ -9,6 +9,9 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
|
||||
@@ -23,9 +26,11 @@ const createPermission = computed(() => isLooseboard.value ? overseasBusinessPer
|
||||
const editPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.truckEdit)
|
||||
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
|
||||
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
|
||||
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
@@ -37,7 +42,7 @@ const truckColumns: DataTableColumn[] = [
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
|
||||
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90, formatter: (row: LocalTransportOrder) => row.containerType || '—' }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
|
||||
@@ -45,7 +50,7 @@ const truckColumns: DataTableColumn[] = [
|
||||
]
|
||||
const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : '否' },
|
||||
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : row.palletRequired === 'NO' ? '否' : '—' },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportTypeLabels[row.transportType] }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
|
||||
@@ -69,7 +74,7 @@ async function load() {
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW' }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
@@ -85,10 +90,10 @@ watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</ListPageLayout><AppDialog v-model="arrangementOpen" class="admin-density-dialog" title="承运安排" width="1080px" :show-confirm="false" cancel-text="关闭"><FleetArrangementSummary v-if="arrangement" :arrangement="arrangement" /></AppDialog></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>.table-panel { overflow:hidden; }</style>
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface LocalTransportQuery {
|
||||
transportType?: LocalTransportType | ''; customerId?: string; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalTransportOrder {
|
||||
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
|
||||
id: string; customerId: string; orderNo: string; transportType: LocalTransportType | null
|
||||
origin: string; destination: string; containerType: string | null; palletCount: number; packageCount: number
|
||||
receivedPalletCount: number; allocatedPalletCount: number; shippedPalletCount: number
|
||||
@@ -83,13 +84,14 @@ export interface UpdateLocalTransportOrderRequest {
|
||||
|
||||
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
|
||||
export type PalletRequired = 'YES' | 'NO'
|
||||
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
|
||||
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
|
||||
export interface LocalLooseboardQuery {
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
|
||||
status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalLooseboardOrder {
|
||||
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired
|
||||
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
|
||||
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired | null
|
||||
transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number
|
||||
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
|
||||
contactName: string | null; contactInfo: string | null; requestedDate: string | null; status: LocalLooseboardStatus
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 海外通用资料与车辆管理
|
||||
|
||||
- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
|
||||
- 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。
|
||||
|
||||
- 车次“在途交接”页签按需挂载`TripHandoverPanel.vue`,沿`fleet-handovers-v1.yaml`读取剩余货物和交接记录;确认用trip:handover,旧资源结束用trip:release,saved刷新车次。原DEPART不重写,司机/车辆任务分别结束。
|
||||
|
||||
- 车队报价`pages/FleetQuotesView.vue`、`api/fleetQuotes.ts`沿`fleet-quotes-v1.yaml`区分采购/销售;原型通用菜单,编辑780px、CSV导入1040px,未知价格与零价分开。出车记录`pages/TripOperationsView.vue`、`api/tripOperations.ts`沿`fleet-operations-v1.yaml`按实际DEPART查询,列表/图表同筛选,缺少成本或财务事实不推为零;分别使用fleet:quote:list与fleet:operations:list,写入/导出独立权限。
|
||||
|
||||
- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,未知新增结果保留原键及内容重试。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。
|
||||
|
||||
- 车辆概览中的派送与更换记录由`components/VehicleFulfillmentTable.vue`、`api/vehicleFulfillment.ts`读取,替代原提示文字。派送要求vehicle:list与delivery:list,更换要求vehicle:list与trip:list;无对应权限不请求接口。两个可见分区各一次分页请求,不逐行加载;换车/换页忽略旧响应,错误保留重试及诊断。更换显示原/新车辆与司机、历史车次及排车业务号;现有快照没有操作人姓名时显示“—”,不用内部操作者ID回退。
|
||||
|
||||
- 适用应用规则见 `../../../AGENTS.md`;原型与交互矩阵为本目录 `PROTOTYPE_MATRIX.md`,新增司机页面使用其中用户提供的新版原型入口。
|
||||
- 报价、汇率、车辆现有 API 在 `api/overseasMasterData.ts`;司机 API 在 `api/drivers.ts`,字段与标签在 `driverTypes.ts`,后端合同为 Lastleg `fleet-drivers-v1.yaml`。
|
||||
- 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。
|
||||
- 用户已于2026-10-02确认首版车次交互:`pages/TripsView.vue`、`pages/TripDetailView.vue` 为独立路由;`TripPlanDialog.vue` 1080px居中计划弹框,`TripAssignmentPanel.vue`、`TripLoadingPanel.vue` 在详情分区。列表一次分页请求,历史/装载/结案检查进入对应页签才读取;候选聚焦或搜索时调用车辆/司机目录,不逐行读取详情。先分配资源、关联排车及匹配卸货站,每张排车单调度审核通过后由PDA拣货、平台确认实测装载、整车理货审核通过后PDA整车发车;平台不提供替代PDA的发车按钮。审核使用独立trip:dispatch-review/trip:loading-review权限,详情展示通过/打回,通过意见选填、打回原因必填,不加审核清单;普通说明选填并提供标准审计说明,仍保留版本、幂等、容量和证照期限约束。
|
||||
- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,CSV使用车牌业务标识并包含容量字段。
|
||||
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
|
||||
- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
|
||||
- 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。
|
||||
- 列表与统计使用同一次服务端响应;统计范围与列表筛选一致,30天提醒按证照时区计算。页面不能用当前分页行数替代总统计,也不能在浏览器重算派车资格。
|
||||
- 姓名/证照关键字段变化与替换驾驶证重新待审;体检、培训和保险当前仅记录日期,不自动成为必需资质。所属仓库使用真实公共目录,不接受自由文本假关联。
|
||||
- `driver:list` 包含详情、历史、资格预览与关联附件读取;创建、编辑、启停、审核、上传权限独立。前端只隐藏/禁用操作,服务端仍需校验。
|
||||
- 保存档案成功而上传失败时保留已生成的司机和版本,重试不得再次创建;版本冲突保留表单及服务端错误。审核或修改成功使用返回详情,不无条件再次请求详情。
|
||||
- 历史按需分页;附件只在打开预览时读取,关闭/卸载释放Blob URL,忽略已关闭后的迟到响应。文件业务错误不能作为图片/PDF预览。
|
||||
- `DriverDetailView` 资格预览随预计结束时间或档案变化失效;只显示待重新校验,不自行构造服务端资格结论。真实派车和发车仍须服务端命令复核。
|
||||
- 定向检查:`pnpm test src/modules/overseas-master-data src/core/navigation/adminNavigation.test.ts src/styles/adminDensity.test.ts`;发布前按应用规则执行类型检查、全量测试和构建。
|
||||
|
||||
- 车次详情“计划容量”页签按需读写`/capacity-plan`,复用`TripLoadingPanel`的计划模式,预计重量/体积与实际装载分别保存;只在该页签展示逐段计划余位。`planValid=false`时不展示旧余位,货物变化后重新核对;保存计划只更新车次版本,不把理货审核变成通过或写入实载。
|
||||
|
||||
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
|
||||
|
||||
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 海外通用资料真实接口边界
|
||||
|
||||
三域统一经 `overseasHttp` 使用浏览器业务路径 `/platform/v1/last-leg/master-data`,由海外 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。
|
||||
三域统一使用共享 `http` 客户端和浏览器业务路径 `/platform/v1/last-leg/master-data`,经当前统一 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。
|
||||
|
||||
- 报价:已接分页、详情、创建、编辑、启停、CSV 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。
|
||||
- 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。
|
||||
@@ -9,7 +9,7 @@
|
||||
当前明确边界:
|
||||
|
||||
- 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。
|
||||
- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。
|
||||
- 车辆详情已接保养、加油、维修、故障、救援和异常记录,合同见 `fleet-vehicle-records-v1.yaml`;派送履约及跨车次更换历史尚未接入,页面提示从现有排车/派送管理查看派送记录,不拼装其他订单数据。
|
||||
- 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。
|
||||
- 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。
|
||||
- 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# 海外通用资料原型核对矩阵
|
||||
|
||||
## 内部拼车需求池首版
|
||||
|
||||
依据:用户授权按现有车次样式整合;唯一范围为 [内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7、9节,没有另引入外部原型。
|
||||
|
||||
| 页面 | 操作 | 确认承载与实现 |
|
||||
| --- | --- | --- |
|
||||
| 拼车需求池 | 可接运力、我的意向、查询和分页 | 独立列表页,integrated DataTable,仅激活页签发请求;一行一发布,多站余位在行内分行 |
|
||||
| 发布详情 | 运输摘要、分段余位、获准意向 | 独立路由;一次详情含首20条意向,后续按需分页;销售不读取车次敏感DTO |
|
||||
| 意向 | 新增、编辑、只读详情 | 1080px居中三列表单;取消/X/Esc/路由离开保护;客户聚焦搜索和报价显式读取 |
|
||||
| 意向 | 确认承运、取消 | 现有居中确认框;服务端允许动作与独立权限;未知结果原键原载荷重试 |
|
||||
| 车次详情 | 发布设置 | 1080px居中三列表单,显式打开才读取;保存、发布、暂停、恢复、关闭各自命令 |
|
||||
| 车次外接预订 | 池来源编辑/确认/取消 | 只读并提示在池中管理;实际来货/实收/关联排车仍走原履约弹框 |
|
||||
|
||||
本轮用户授权内置浏览器1920×1080验收;自动测试与实际浏览器结果分别记录,未验收状态不推定通过。
|
||||
|
||||
## 2026-10-07 三个车辆列表样式统一
|
||||
|
||||
依据:本轮用户提供的司机管理、车次管理、出车记录三张截图;对齐已确认的 `VehiclesView.vue` 连续列表及 `docs/Admin高密度界面规范.md`。仅改列表容器与排版,不改变下列操作承载、权限或请求。
|
||||
|
||||
| 页面 | 操作与当前承载 | 本轮结论 |
|
||||
|---|---|---|
|
||||
| 司机管理 | 查询/到期统计筛选;详情独立路由;新增/编辑1080px居中;证照预览620px居中;启停原因确认;审核在详情 | 列表接入integrated标题、筛选、统计、表格、分页;三项统计保留点击筛选,紧凑排列;各操作承载保持 |
|
||||
| 车次管理 | 查询;新建1080px计划弹框;详情独立路由;编辑、审核、确认在详情;列表无文件预览/删除 | 接入相同integrated列表,详情操作保留在第一列;弹框与详情保持 |
|
||||
| 出车记录 | 日期/货源筛选、图表和汇总;导出;车次详情独立路由;实测记录820px居中及其中凭证预览;列表无删除/审核 | 图表、说明与汇总放入连续列表内,统一间距,空图表不重复显示空系列标题;保留未知金额、成本及统计说明 |
|
||||
|
||||
改后视觉结论待用户在1920×1080、100%缩放提供截图确认;自动检查单独记录,不以测试代替视觉通过。
|
||||
|
||||
事实来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/10-general.html`、`prototype-src/overlays.html`、`prototype-src/app.js`。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前实现 | 结论 |
|
||||
@@ -13,8 +40,77 @@
|
||||
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3673-3699` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` |
|
||||
| 汇率管理 | 更新/编辑 | `overlays.html:1695-1704`、`app.js:3701-3728` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 |
|
||||
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:10437-10456`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 已接真实分页;区域降为查询下拉框,仅保留自有/外派归属页签;操作列按全局约定移到第一列 | 当前对话确认高于原型;查询提交 ownership/region/status,不拼装数据 |
|
||||
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:10546-10591` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 |
|
||||
| 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 |
|
||||
| 车辆 | 详情 | `10-general.html`的`page-vehicle-detail`、`vehicle-detail-orders-title`(455)、`vehicle-detail-history-title`(459) | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录;只读表不添加操作列 | 已接车辆详情和两个分页只读投影;派送保留单号、BOL/来源、客户、目的地、预约日期、状态和关联关系;车次统一改派按事件展示原/新车辆及司机和关联排车,不虚构逐派送改派事件。操作人姓名未记录时为“—” | 按稳定车辆身份及不可变历史关联,不按车牌猜测;使用派送/车次各自只读权限。真实1920×1080页面及请求量待新接口发布后验收 |
|
||||
| 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529`;本次车辆容量设计见车辆管理规划第4.1节 | 居中弹框 `modal-vehicle-edit` | 原760px两列;新增托位/载重/容积后按高密度规范扩为1080px三列 | 已接创建/编辑、独立状态与导出;三项容量同填或全空,不从车型文本推算 | create/edit/status 权限独立;占用中关键变更由服务端拒绝 |
|
||||
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:10437-10456` | 删除为列表行操作;导出在工具栏 | 原型列表操作 | 已接真实 CSV 导出;服务端无删除端点,未展示删除 | vehicle:export 独立校验;不推断 DELETE |
|
||||
|
||||
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
|
||||
旧版原型未定义的车辆文件预览、审核/确认流程不自行补充;新版保养及加油照片按下表的原型链接行为接入。
|
||||
|
||||
## 新版车辆保养及加油接入矩阵
|
||||
|
||||
输入与新版司机相同。范围是车辆详情内的保养、加油记录;维修、故障、救援、异常和财务关联尚未在这一增量实现。
|
||||
|
||||
| 页面/操作 | 原型证据 | 原型承载及尺寸 | 实施口径 |
|
||||
| --- | --- | --- | --- |
|
||||
| 车辆详情保养及加油 | `新版.html:9033、9044`,`vehicle-detail-ops`;`22882–22892` | 独立车辆详情内页签,保养/加油两组表格 | 按当前车辆分别分页,进入页签才请求,不把其他车记录带入;操作列第一列 |
|
||||
| 新增/编辑保养与加油 | `8937` 的 `modal-vehicle-ops-record`、`22903–22916` | 680px 居中弹框,两列表单,底部取消/保存 | 沿用字段分组;明确里程及加油单位,实际时间带时区提交;更正保留原因与版本,不自动改车辆主档里程 |
|
||||
| 查看记录图片 | `22891–22892` 的 `target="_blank"` 图片链接 | 新标签页查看图片 | 点击时鉴权下载已关联图片,以临时Blob打开,关闭释放;不把JSON错误当图片,不新增预览抽屉 |
|
||||
| 删除 | `22917` 附近 `deleteVehicleOpsRecord`,浏览器confirm | 删除确认 | 删除/作废语义仍待业务明确,本增量不注册删除端点或展示假动作 |
|
||||
| 核验状态 | `22906` 加油表单及车辆保养表单 | 表单内字段 | 保存人工核验状态,不替代财务付款或车辆可派资格;不增加审批工作流 |
|
||||
|
||||
真实1920×1080视觉和交互验收待执行;接口与自动测试分别记录,不视为视觉通过。
|
||||
|
||||
## 新版维修、故障、救援及异常接入矩阵
|
||||
|
||||
| 页面/操作 | 原型证据 | 原型承载及尺寸 | 实施口径 |
|
||||
| --- | --- | --- | --- |
|
||||
| 维修、紧急故障、应急救援、异常列表 | `新版.html:9045–9048` 的四个 `vehicle-detail-*` 分区 | 车辆详情内四个页签,各自表格 | 只查询当前车辆和当前类型,切换时忽略旧响应;不把原型全局数组当作跨车共享记录 |
|
||||
| 四类新增 | `8938` 的 `modal-vehicle-detail-add`、`23275` 的 `openVehicleDetailAdd` | 620px居中弹框,日期/处理人、描述、费用/状态 | 按类型明确字段;维修/救援项目与地点单独输入并保存,修复原型读写错配;金额含明确币种,处理状态与付款信息分开 |
|
||||
| 异常编辑 | `23270–23273` 的 `editVehicleException`,`9048` 行操作 | 复用620px居中弹框 | 保留版本与更正原因;操作列按全局要求放第一列 |
|
||||
| 维修/故障/救援更新处理 | 三类原型仅新增;2026-10-02 用户同意按建议出首版 | 列表“更新处理”,复用620px原弹框 | 已开放独立编辑权限的更新处理;更正说明选填,未填记录标准操作说明 |
|
||||
| 费用与付款 | `9047` 付款状态列与原弹框处理状态混用 | 付款为列表只读信息 | 当前无财务关联时服务端明确返回UNLINKED,显示“未关联财务”;不把COMPLETED映射为已付款,不显示无来源实付金额 |
|
||||
| 图片、删除 | 四类弹框未定义图片或删除入口 | 未定义 | 本增量不为这四类新增图片弹框、删除按钮或作废动作 |
|
||||
|
||||
以上仅为代码接入依据,真实界面验收仍待执行。
|
||||
|
||||
## 新版报价与出车记录接入
|
||||
|
||||
| 入口 | 原型依据 | 当前承载 | 核对边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| 车队报价 | `新版.html:8989–9021`,通用菜单`1527` | 独立列表,780px居中编辑,1040px居中CSV导入;采购/销售分开 | 列表权限含详情历史,写入与导出独立;整批导入,未知价不转零;真实1920×1080截图待发布后确认 |
|
||||
| 出车记录 | `新版.html:8941–8987`,车辆菜单`1457` | 独立列表与统计,同一实际发车筛选口径,详情链接沿车次权限 | 不用原型内存记录创建发车事实;成本/财务未查明时明确显示未查询;首版CSV导出不声称原生Excel工作簿,页面与请求量待1920×1080真实验收 |
|
||||
| 出车实测凭证 | `新版.html:8941–8987`、`modal-vehicle-trip`(8966);车辆规划第4.1节的实测凭证补充 | 沿现有820px居中实测弹框,附件选填;PDF/JPG/PNG,单个不超过10MiB、最多20个 | 平台0+专用业务类型+车次归属校验;保存后当前及历史版本按需预览,不逐行GET;零附件合法,移除当前引用保留历史。上传不创建实测记录;未知上传结果停止重传并保留文件名及诊断,允许暂不绑定。迟到响应不串车次;1920×1080、100%缩放视觉与请求量待真实验收 |
|
||||
|
||||
## 新版司机管理接入矩阵
|
||||
|
||||
输入为用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-09/新版.html`,哈希及业务确认见 `docs/海外仓业务/车辆管理原型梳理与任务规划.md`。下列为本次实现依据,真实页面验收尚待执行。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 尺寸/布局与实施结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 司机管理 | 列表/筛选 | `8798–8805`,`page-driver-management` | 独立列表页,三张可见KPI | 保留总数、30天到期、已过期;共用服务端筛选统计,操作列按全局约定移至第一列;状态拆分为启停、审核和证照有效期,不伪造任务中状态 |
|
||||
| 司机管理 | 详情 | `8807–8813`,`page-driver-management-detail` | 独立详情页 | 基础信息、驾驶资质、证照与派车状态;使用正式详情路由,连续四列字段与返回入口 |
|
||||
| 司机管理 | 新增/编辑 | `8815`,`modal-driver-edit` | 居中弹框,原宽820px | 多字段按高密度规范使用1080px三列表单,保留字段分组;可先保存待补件档案,证照上传失败可重试,不重复创建司机;审核状态改由独立命令维护 |
|
||||
| 司机管理 | 文件预览 | `8816`,`modal-driver-license-view` | 620px居中弹框 | 真实图片/PDF预览,当前或历史已关联附件鉴权读取,关闭即释放临时预览地址 |
|
||||
| 司机管理 | 审核/打回 | 当前对话确认“详情展示、点击通过或者打回” | 原型无独立审核弹框 | 在详情的证照区域显示操作、意见与处理结果;打回必填原因,独立review权限;不在编辑表单里选择已审核 |
|
||||
| 司机管理 | 启停 | 原型删除使用浏览器confirm;停用为本次明确的服务端命令 | 居中确认 | 保留档案与历史,启停填写原因;不将停用描述成物理删除 |
|
||||
|
||||
## 共享车次新增交互(2026-10-02 用户确认首版方案)
|
||||
|
||||
成本工作台沿车辆规划第4.1节“一趟直接成本及自有/外接分摊”的已确认方案,在车次详情增加“直接成本”页签。费用登记/更正使用680px居中两列表单,历史使用1080px居中只读表;原型出车记录的笼统价格不直接当成本或利润。分页查询同时返回服务端同币种汇总和基于实运托位的分摊,未知值明确显示未登记/暂未分摊。对应`TripCostPanel.vue`及`tripCosts.ts`;真实1920×1080页面、交互和请求量待结构准备及发布后验证。
|
||||
|
||||
业务依据为当前车辆管理规划第4.1节。原型出车记录 `新版.html:8941–8987`、`modal-vehicle-trip`(8966)仅展示记录、统计和新增/编辑弹框,没有共享车次关联及实际装载工作台。用户已明确“按你的建议执行,先做一个版本再优化”,采用独立车次列表与详情页,计划居中弹框,分配/关联/装载在详情分区,结案和资源释放分别确认。首版普通说明选填,不增加格式校验清单;保留权限、归属、版本、容量和证照期限门禁。
|
||||
|
||||
| 页面/操作 | 原型证据 | 建议承载 | 当前结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 车次列表、详情 | 出车记录列表;无共享车次详情定义 | 独立列表与详情页,按已确认密度规范排版 | 已按用户确认接入首版;真实页面验收待执行 |
|
||||
| 创建、编辑计划 | 出车记录新增/编辑为 `modal-vehicle-trip`;新计划语义另见业务确认 | 居中计划弹框,路线表格整行 | 已按确认方案接入计划弹框;不将原型手填统计值当作计划字段 |
|
||||
| 分配车辆司机、关联/解除排车 | 新业务规则,无原型对应操作 | 详情页内分区 | 已按用户确认接入首版;真实页面验收待执行 |
|
||||
| 调度/理货审核 | 原型有详情、通过及打回;已确认调度先于拣货、理货先于发车 | 关联排车行独立审核,实际装载分区整车理货审核,打回居中填写原因 | 首版已接入独立权限和服务端审核状态;真实页面验收待执行 |
|
||||
| 外接询价、详情与承运占位 | 用户确认运营后台登记外接业务;首版不扩展客户在线撮合 | 车次详情外接页签、1080px居中三列表单,详情只读、确认/取消居中确认;操作列随计划/执行阶段使用320/170px,保留原行操作 | 已接登记/编辑/确认/取消、独立权限及已确认预订余位;已接真实来货登记、实收复核和排车关联。2026-10-03截图`fleet-external-booking-1920.png`及DOM复现旧440px操作列使1640px详情容器内表格宽1720px;本次仅收窄闲置列宽,部署后的1920×1080复验仍待完成 |
|
||||
| 计划容量与剩余运力 | 本轮外接剩余运力方案,原型无完整占位工作台 | 车次详情独立页签,复用托盘核对表,显示预计值和每段剩余托位/kg/m³ | 源码已接计划容量;已扣除确认承运的外接占位;不把预订当实物装载 |
|
||||
| 实际装载确认 | 新业务规则,无原型对应操作 | 详情页内实物托盘表及确认区 | 已按用户确认接入首版;真实页面验收待执行 |
|
||||
| 货物结案、结束车辆/司机任务 | 新业务规则,无原型对应操作 | 详情页展示服务端检查结果,提交前居中确认 | 已接独立命令和确认;结案与资源释放独立。原实发、实签、本趟实退及未结箱数分别展示,混合结果显示“余货已签收”;一次结案查询提供数量,不逐票补请求,1920×1080真实视觉待验 |
|
||||
|
||||
| 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 |
|
||||
|
||||
| 出车运营应收/实收读取 | `新版.html:8941–8987` 出车记录列表;车辆规划第4.1节及本轮明确确认的Finance权威口径 | 沿原运营列表应收/实收列,点击读取;金额来源在620px只读浮层展示,不新增菜单或财务写表单 | 单车次按需读取,未分配明确显示待分配,缺权限不请求;真实1920×1080、100%视觉及请求量待总控验收 |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listCapacityOffers, listCapacityInquiries, listOfferCustomers, listOfferQuotes, normalizeInquiry, normalizeOffer, saveCapacityInquiry, commandCapacityInquiry } from './capacityOffers'
|
||||
import type { CapacityOffer, CapacityInquiry, InquiryWrite } from '../capacityOfferTypes'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
const offer = { offerNo: 'CO/1', tripNo: 'TR-1', recordVersion: '2', tripRecordVersion: '3', capacityKnown: false, destinations: [{ destinationKey: 'FBA:X', name: 'X', deliveryIndex: '1', remaining: null }], segments: [{ fromIndex: '0', toIndex: '1', fromName: '我仓', toName: 'X', planned: null, reserved: { palletSpaces: '0', weightKg: '0', volumeCbm: '0' }, remaining: null }] } as unknown as CapacityOffer
|
||||
const inquiry = { bookingNo: 'FB/1', offerNo: 'CO/1', recordVersion: '1', tripRecordVersion: '3', offerRecordVersion: '2', expectedPalletCount: null, agreedAmount: null, quoteSource: null, cargo: { packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: '0' } } as unknown as CapacityInquiry
|
||||
const envelope = <T,>(data: T) => ({ code: '200', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('keeps unknown quantities distinct from a known zero price and normalizes segment facts', () => {
|
||||
expect(normalizeInquiry(inquiry)).toMatchObject({ recordVersion: 1, expectedPalletCount: null, agreedAmount: null, cargo: { packageCount: null, unitPrice: 0 } })
|
||||
expect(normalizeOffer(offer)).toMatchObject({ capacityKnown: false, destinations: [{ remaining: null }], segments: [{ planned: null, reserved: { palletSpaces: 0 }, remaining: null }] })
|
||||
})
|
||||
it('uses only the dedicated pool endpoint for sales reads and forwards the requested page once', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [offer], total: '1', page: '2', size: '20', pages: '2' })).mockResolvedValueOnce(envelope({ records: [inquiry], total: '1', page: '1', size: '20', pages: '1' })).mockResolvedValueOnce(envelope([{ id: '9007199254740993', code: 'C-1', name: '测试客户' }]))
|
||||
await listCapacityOffers({ page: 2, size: 20 }); await listCapacityInquiries({ scope: 'MINE' }); const customers = await listOfferCustomers('测试')
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers', { params: { page: 2, size: 20 } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/inquiries', { params: { scope: 'MINE' } })
|
||||
expect(customers[0]?.id).toBe('9007199254740993'); expect(http.get).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
it('submits one command with encoded business numbers and an unchanged retry key', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(envelope({ offer, inquiry, replayed: false }))
|
||||
const body = { customerId: '19', idempotencyKey: 'stable-test-key', cargo: { packageCount: null } } as InquiryWrite
|
||||
await saveCapacityInquiry('CO/1', null, body)
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/inquiries', body)
|
||||
const command = { recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 3, idempotencyKey: 'confirm-stable-key' }
|
||||
await commandCapacityInquiry('FB/1', 'confirm', command)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/capacity-offers/inquiries/FB%2F1/confirm', command)
|
||||
})
|
||||
it('reads both quote pages from the offer-scoped URL and preserves the exact source version', async () => {
|
||||
const source = { quoteNo: 'FQ-POOL', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY', customerId: '19', customerName: '测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '35', currency: 'CNY', priceBasis: 'PALLET', quotedUnitPrice: '25.50', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [source], page: '1', size: '20', total: '21', pages: '2' })).mockResolvedValueOnce(envelope({ records: [{ ...source, quoteNo: 'FQ-POOL-SECOND' }], page: '2', size: '20', total: '21', pages: '2' }))
|
||||
const criteria = { customerId: '19', destinationKey: 'FBA:ONT8', currency: 'CNY' as const, priceBasis: 'PALLET' as const, page: 1, size: 20 }
|
||||
const first = await listOfferQuotes('CO/1', criteria)
|
||||
const second = await listOfferQuotes('CO/1', { ...criteria, page: 2 })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/quote-candidates', { params: criteria })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/quote-candidates', { params: { ...criteria, page: 2 } })
|
||||
expect(http.get).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(http.get).mock.calls.every(([url]) => !url.includes('/fleet/trips/'))).toBe(true)
|
||||
expect(first.records[0]).toMatchObject({ quoteNo: source.quoteNo, recordVersion: source.recordVersion, quotedUnitPrice: 25.5, distanceMiles: 35 })
|
||||
expect(second).toMatchObject({ page: 2, size: 20, total: 21, pages: 2, records: [{ quoteNo: 'FQ-POOL-SECOND', recordVersion: source.recordVersion }] })
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CapacityOffer, CapacityInquiry, CapacityOfferDetail, CapacityLoad, OfferPage, OfferQuery, InquiryQuery, OfferWrite, OfferCommand, InquiryWrite, InquiryCommand, OfferResult, InquiryResult, OfferCustomerOption } from '../capacityOfferTypes'
|
||||
import { normalizeFleetQuotePage, normalizeFleetQuoteSource, type WireQuotePage } from './fleetQuoteCandidates'
|
||||
import type { TripBookingQuoteCriteria } from './tripBookings'
|
||||
const base = '/platform/v1/last-leg/fleet/capacity-offers'
|
||||
const path = (value: string) => `${base}/${encodeURIComponent(value)}`
|
||||
const inquiryPath = (value: string) => `${base}/inquiries/${encodeURIComponent(value)}`
|
||||
const number = (value: number) => { const n = Number(value); if (value == null || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
|
||||
const nullable = (value: number | null) => value == null ? null : number(value)
|
||||
const load = (value: CapacityLoad | null) => value == null ? null : { palletSpaces: number(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) }
|
||||
export function normalizeOffer(value: CapacityOffer): CapacityOffer { return { ...value, recordVersion: number(value.recordVersion), tripRecordVersion: number(value.tripRecordVersion), destinations: value.destinations.map(d => ({ ...d, deliveryIndex: number(d.deliveryIndex), remaining: load(d.remaining) })), segments: value.segments.map(s => ({ ...s, fromIndex: number(s.fromIndex), toIndex: number(s.toIndex), planned: load(s.planned), reserved: load(s.reserved), remaining: load(s.remaining) })) } }
|
||||
export function normalizeInquiry(value: CapacityInquiry): CapacityInquiry { return { ...value, recordVersion: number(value.recordVersion), offerRecordVersion: number(value.offerRecordVersion), tripRecordVersion: number(value.tripRecordVersion), expectedPalletCount: nullable(value.expectedPalletCount), agreedAmount: nullable(value.agreedAmount), quoteSource: value.quoteSource ? normalizeFleetQuoteSource(value.quoteSource) : null, cargo: { ...value.cargo, packageCount: nullable(value.cargo.packageCount), palletSpaces: nullable(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } } }
|
||||
function page<T>(value: OfferPage<T>, normalize: (row: T) => T): OfferPage<T> { return { records: value.records.map(normalize), page: number(value.page), size: number(value.size), total: number(value.total), pages: number(value.pages) } }
|
||||
const offerResult = (value: OfferResult): OfferResult => ({ ...value, offer: normalizeOffer(value.offer) })
|
||||
const inquiryResult = (value: InquiryResult): InquiryResult => ({ ...offerResult(value), inquiry: normalizeInquiry(value.inquiry) })
|
||||
async function get<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
async function put<T>(url: string, body: object) { return unwrapApiResponse(await http.put<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
export async function listCapacityOffers(query: OfferQuery) { return page(await get<OfferPage<CapacityOffer>>(base, query), normalizeOffer) }
|
||||
export async function getCapacityOffer(offerNo: string): Promise<CapacityOfferDetail> { const value = await get<CapacityOfferDetail>(path(offerNo)); return { offer: normalizeOffer(value.offer), inquiries: page(value.inquiries, normalizeInquiry) } }
|
||||
export async function listCapacityInquiries(query: InquiryQuery) { return page(await get<OfferPage<CapacityInquiry>>(`${base}/inquiries`, query), normalizeInquiry) }
|
||||
export async function getCapacityInquiry(bookingNo: string) { return normalizeInquiry(await get<CapacityInquiry>(inquiryPath(bookingNo))) }
|
||||
export async function saveCapacityOffer(offerNo: string | null, body: OfferWrite) { return offerResult(await (offerNo ? put<OfferResult>(path(offerNo), body) : post<OfferResult>(base, body))) }
|
||||
export async function commandCapacityOffer(offerNo: string, action: 'publish' | 'pause' | 'resume' | 'close', body: OfferCommand) { return offerResult(await post<OfferResult>(`${path(offerNo)}/${action}`, body)) }
|
||||
export async function saveCapacityInquiry(offerNo: string, bookingNo: string | null, body: InquiryWrite) { return inquiryResult(await (bookingNo ? put<InquiryResult>(inquiryPath(bookingNo), body) : post<InquiryResult>(`${path(offerNo)}/inquiries`, body))) }
|
||||
export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/${action}`, body)) }
|
||||
export const listOfferCustomers = (keyword?: string) => get<OfferCustomerOption[]>(`${base}/customer-options`, { keyword })
|
||||
export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get<WireQuotePage>(`${path(offerNo)}/quote-candidates`, query)) }
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http, ApiResponseError } from '@/shared/api/http'
|
||||
import { getCompanyCost, commandCompanyCost, payCompanyCost, listCompanyCostHistory, listCompanyCostPayments, uploadCompanyCostVoucher, downloadCompanyCostVoucher } from './companyVehicleCosts'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
const source = { sourceType: 'REPAIR', sourceNo: 'VR-ONE', sourceVersion: '1', vehicleId: '90071992547409931', vehiclePlate: 'VEH-A', tripNo: null,
|
||||
supplierId: '90071992547409932', feeName: '维修', businessDate: '2026-01-01', originalCurrency: 'USD', originalAmount: '10.00', exchangeRate: '7.000000', exchangeRateMonth: '2026-01', amount: '70.00', remark: null }
|
||||
const wire = { costNo: 'VC-ONE', subjectCode: 'PLATFORM_DEFAULT', supplierName: '维修供应商', currency: 'CNY', status: 'APPROVED', recordVersion: '90071992547409933', paidAmount: '0.00', outstandingAmount: '70.00', source }
|
||||
const base = '/platform/finance/v1/vehicle-costs/VC-ONE'
|
||||
const ok = (data: unknown) => ({ code: '200', data })
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
it('reads real company detail while preserving long versions and original-currency facts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(ok(wire))
|
||||
const result = await getCompanyCost('VC-ONE')
|
||||
expect(result).toMatchObject({ recordVersion: wire.recordVersion, paidAmount: 0, outstandingAmount: 70,
|
||||
source: { supplierId: source.supplierId, originalAmount: 10, amount: 70, exchangeRate: 7 } })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(base)
|
||||
})
|
||||
it('sends exact action versions and stable Idempotency-Key headers for booking, void and payment', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok(wire))
|
||||
const command = { expectedVersion: wire.recordVersion, reason: '核对' }
|
||||
await commandCompanyCost('VC-ONE', 'approve', 'approve-key', command)
|
||||
await commandCompanyCost('VC-ONE', 'void', 'void-key', command)
|
||||
const payment = { expectedVersion: wire.recordVersion, amount: 20, paidAt: '2026-01-02T00:00:00Z', method: 'BANK_TRANSFER' as const, referenceNo: null, voucherFileIds: ['90071992547409934'], remark: null }
|
||||
await payCompanyCost('VC-ONE', 'payment-key', payment)
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, `${base}/approve`, command, { headers: { 'Idempotency-Key': 'approve-key' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, `${base}/void`, command, { headers: { 'Idempotency-Key': 'void-key' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(3, `${base}/payments`, payment, { headers: { 'Idempotency-Key': 'payment-key' } })
|
||||
})
|
||||
it('loads separately paged history and payment records without treating unknown payable as zero', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [{ recordVersion: '0', action: 'RECEIVE', reason: null, operatorName: '系统', occurredAt: '2026-01-01T00:00:00Z', snapshot: { ...wire, status: 'PENDING', outstandingAmount: null } }], page: 2, size: 10, total: 11, pages: 2 }))
|
||||
const history = await listCompanyCostHistory('VC-ONE', 2)
|
||||
expect(history.records[0]?.snapshot.outstandingAmount).toBeNull()
|
||||
vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [{ paymentNo: 'VP-ONE', currency: 'CNY', amount: '20.00', voucherFileIds: ['90071992547409934'] }], page: 1, size: 10, total: 1, pages: 1 }))
|
||||
const payments = await listCompanyCostPayments('VC-ONE')
|
||||
expect(payments.records[0]).toMatchObject({ amount: 20, voucherFileIds: ['90071992547409934'] })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, `${base}/history`, { params: { page: 2, size: 10 } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, `${base}/payments`, { params: { page: 1, size: 10 } })
|
||||
})
|
||||
it('uploads only to the selected cost and rejects customer or operational photographs as payment evidence', async () => {
|
||||
const file = new File(['png'], 'payment.png', { type: 'image/png' })
|
||||
vi.mocked(http.post).mockResolvedValueOnce(ok({ fileId: '90071992547409934', fileName: 'payment.png', contentType: 'image/png', sizeBytes: '3', ownerScope: 'PLATFORM', tenantId: '0', businessType: 'FINANCE_VEHICLE_VOUCHER' }))
|
||||
expect(await uploadCompanyCostVoucher('VC-ONE', file)).toMatchObject({ fileId: '90071992547409934', sizeBytes: 3 })
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe(`${base}/vouchers`)
|
||||
expect((vi.mocked(http.post).mock.calls[0]?.[1] as FormData).get('file')).toBe(file)
|
||||
vi.mocked(http.post).mockResolvedValueOnce(ok({ fileId: '1', ownerScope: 'CUSTOMER', tenantId: '12', businessType: 'FINANCE_VOUCHER' }))
|
||||
await expect(uploadCompanyCostVoucher('VC-ONE', file)).rejects.toThrow('凭证返回内容无效')
|
||||
})
|
||||
it('keeps JSON download errors as diagnostic errors rather than successful images', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'FIN_404', message: '不存在', requestId: 'request-one', traceId: 'trace-one' })], { type: 'application/json' }))
|
||||
await expect(downloadCompanyCostVoucher('VC-ONE', '11')).rejects.toBeInstanceOf(ApiResponseError)
|
||||
})
|
||||
it('rejects mismatched costs and imprecise money instead of displaying successful facts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(ok({ ...wire, costNo: 'VC-OTHER' })).mockResolvedValueOnce(ok({ ...wire, paidAmount: '90071992547409.92' }))
|
||||
await expect(getCompanyCost('VC-ONE')).rejects.toThrow('归属')
|
||||
await expect(getCompanyCost('VC-ONE')).rejects.toThrow('精确展示')
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import axios from 'axios'
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
export const companyCostPermissions = {
|
||||
list: 'platform:finance:vehicle-cost:list', approve: 'platform:finance:vehicle-cost:approve',
|
||||
void: 'platform:finance:vehicle-cost:void', pay: 'platform:finance:vehicle-cost:pay', upload: 'platform:finance:vehicle-cost:upload',
|
||||
} as const
|
||||
export type CompanyCostState = 'PENDING' | 'APPROVED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
||||
export interface CompanyCost {
|
||||
costNo: string; subjectCode: 'PLATFORM_DEFAULT'; supplierName: string; currency: 'CNY'; status: CompanyCostState
|
||||
recordVersion: string; paidAmount: number; outstandingAmount: number | null
|
||||
source: { sourceType: string; sourceNo: string; sourceVersion: string; vehicleId: string; vehiclePlate: string; tripNo: string | null;
|
||||
supplierId: string; feeName: string; businessDate: string; originalCurrency: 'CNY' | 'USD'; originalAmount: number;
|
||||
exchangeRate: number; exchangeRateMonth: string; amount: number; remark: string | null }
|
||||
}
|
||||
export interface CompanyCostCommand { expectedVersion: string; reason: string | null }
|
||||
export interface CompanyCostPayment {
|
||||
expectedVersion: string; amount: number; paidAt: string; method: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
||||
referenceNo: string | null; voucherFileIds: string[]; remark: string | null
|
||||
}
|
||||
export interface CompanyCostHistory { recordVersion: string; action: string; reason: string | null; operatorName: string; occurredAt: string; snapshot: CompanyCost }
|
||||
export interface CompanyCostPaid { paymentNo: string; currency: 'CNY'; amount: number; paidAt: string; method: string; referenceNo: string | null; voucherFileIds: string[]; operatorName: string; recordedAt: string; remark: string | null }
|
||||
export interface CompanyCostVoucher { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
type Numeric = string | number
|
||||
type CostWire = Omit<CompanyCost, 'recordVersion' | 'paidAmount' | 'outstandingAmount' | 'source'> & {
|
||||
recordVersion: Numeric; paidAmount: Numeric; outstandingAmount?: Numeric | null
|
||||
source: Omit<CompanyCost['source'], 'sourceVersion' | 'supplierId' | 'originalAmount' | 'exchangeRate' | 'amount'> & {
|
||||
sourceVersion: Numeric; supplierId: Numeric; originalAmount: Numeric; exchangeRate: Numeric; amount: Numeric }
|
||||
}
|
||||
const base = (no: string) => `/platform/finance/v1/vehicle-costs/${encodeURIComponent(no)}`
|
||||
function version(value: Numeric): string {
|
||||
if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('公司成本版本或标识无效')
|
||||
return String(value)
|
||||
}
|
||||
function money(value: Numeric): number {
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('公司成本金额无效')
|
||||
const [whole, fraction = ''] = String(value).split('.')
|
||||
const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0'))
|
||||
if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('公司成本金额超出精确展示范围')
|
||||
return Number(value)
|
||||
}
|
||||
function integer(value: Numeric): number {
|
||||
const result = Number(version(value))
|
||||
if (!Number.isSafeInteger(result)) throw new Error('公司成本分页数值无效')
|
||||
return result
|
||||
}
|
||||
function cost(value: CostWire, expected?: string): CompanyCost {
|
||||
if ((expected && value.costNo !== expected) || value.subjectCode !== 'PLATFORM_DEFAULT' || value.currency !== 'CNY'
|
||||
|| !['PENDING', 'APPROVED', 'PARTIALLY_PAID', 'PAID', 'VOIDED'].includes(value.status)) throw new Error('公司成本响应归属或状态无效')
|
||||
const rate = Number(value.source.exchangeRate)
|
||||
if (!Number.isFinite(rate) || rate <= 0 || !['CNY', 'USD'].includes(value.source.originalCurrency)) throw new Error('公司成本汇率或原币无效')
|
||||
return { ...value, recordVersion: version(value.recordVersion), paidAmount: money(value.paidAmount),
|
||||
outstandingAmount: value.outstandingAmount == null ? null : money(value.outstandingAmount), source: { ...value.source,
|
||||
sourceVersion: version(value.source.sourceVersion), supplierId: version(value.source.supplierId), originalAmount: money(value.source.originalAmount),
|
||||
exchangeRate: rate, amount: money(value.source.amount), tripNo: value.source.tripNo ?? null, remark: value.source.remark ?? null } }
|
||||
}
|
||||
type PageWire<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
function page<T, R>(value: PageWire<T>, convert: (value: T) => R): PageResult<R> {
|
||||
return { records: value.records.map(convert), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) }
|
||||
}
|
||||
export async function getCompanyCost(no: string): Promise<CompanyCost> {
|
||||
return cost(unwrapApiResponse(await http.get<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(base(no))), no)
|
||||
}
|
||||
export async function commandCompanyCost(no: string, action: 'approve' | 'void', key: string, request: CompanyCostCommand): Promise<CompanyCost> {
|
||||
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${base(no)}/${action}`, request, { headers: { 'Idempotency-Key': key } })), no)
|
||||
}
|
||||
export async function payCompanyCost(no: string, key: string, request: CompanyCostPayment): Promise<CompanyCost> {
|
||||
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${base(no)}/payments`, request, { headers: { 'Idempotency-Key': key } })), no)
|
||||
}
|
||||
export async function listCompanyCostHistory(no: string, currentPage = 1): Promise<PageResult<CompanyCostHistory>> {
|
||||
type HistoryWire = Omit<CompanyCostHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: CostWire }
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<HistoryWire>>, ApiEnvelope<PageWire<HistoryWire>>>(`${base(no)}/history`, { params: { page: currentPage, size: 10 } }))
|
||||
return page(value, row => ({ ...row, recordVersion: version(row.recordVersion), snapshot: cost(row.snapshot, no) }))
|
||||
}
|
||||
export async function listCompanyCostPayments(no: string, currentPage = 1): Promise<PageResult<CompanyCostPaid>> {
|
||||
type PaidWire = Omit<CompanyCostPaid, 'amount' | 'voucherFileIds'> & { amount: Numeric; voucherFileIds: Numeric[] }
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<PaidWire>>, ApiEnvelope<PageWire<PaidWire>>>(`${base(no)}/payments`, { params: { page: currentPage, size: 10 } }))
|
||||
return page(value, row => {
|
||||
if (row.currency !== 'CNY') throw new Error('公司付款币种无效')
|
||||
return { ...row, amount: money(row.amount), voucherFileIds: row.voucherFileIds.map(version) }
|
||||
})
|
||||
}
|
||||
export async function uploadCompanyCostVoucher(no: string, file: File): Promise<CompanyCostVoucher> {
|
||||
type Wire = { fileId: Numeric; fileName: string; contentType: string; sizeBytes: Numeric; ownerScope: string; tenantId: Numeric; businessType: string }
|
||||
const form = new FormData(); form.append('file', file)
|
||||
const value = unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${base(no)}/vouchers`, form))
|
||||
if (value.ownerScope !== 'PLATFORM' || String(value.tenantId) !== '0' || value.businessType !== 'FINANCE_VEHICLE_VOUCHER'
|
||||
|| !['image/jpeg', 'image/png'].includes(value.contentType) || integer(value.sizeBytes) <= 0) throw new Error('公司付款凭证返回内容无效')
|
||||
return { fileId: version(value.fileId), fileName: value.fileName, contentType: value.contentType, sizeBytes: integer(value.sizeBytes) }
|
||||
}
|
||||
async function rejectFileError(value: unknown, status: number): Promise<void> {
|
||||
if (value instanceof Blob && value.type.includes('json')) {
|
||||
try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('付款凭证返回了无法识别的错误') }
|
||||
}
|
||||
if (value && typeof value === 'object' && 'code' in value) {
|
||||
const error = value as ApiEnvelope<unknown>
|
||||
throw new ApiResponseError(error.message || '付款凭证读取失败', error.code, status, error.data, error.requestId, error.traceId)
|
||||
}
|
||||
}
|
||||
export async function downloadCompanyCostVoucher(no: string, fileId: string): Promise<Blob> {
|
||||
try {
|
||||
const value = await http.get<Blob, Blob>(`${base(no)}/vouchers/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' })
|
||||
await rejectFileError(value, 200)
|
||||
if (!(value instanceof Blob) || !value.size || !['image/png', 'image/jpeg'].includes(value.type)) throw new Error('付款凭证为空或格式无效')
|
||||
return value
|
||||
} catch (cause) {
|
||||
if (axios.isAxiosError(cause)) await rejectFileError(cause.response?.data, cause.response?.status || 0)
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { changeDriverStatus, createDriver, downloadDriverLicense, getDriver, getDriverQualification, listDriverHistory, listDrivers, reviewDriver, submitDriver, updateDriver, uploadDriverLicense } from './drivers'
|
||||
import type { DriverDraft } from '../driverTypes'
|
||||
|
||||
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } }))
|
||||
const id = '9223372036854775701'
|
||||
const fileId = '9223372036854775801'
|
||||
const base = '/platform/v1/last-leg/fleet/drivers'
|
||||
const details: DriverDraft = { name: '测试司机', phone: 'test-phone', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: null }
|
||||
const record = { id, driverNo: 'DR-TEST', details, active: true, reviewStatus: 'APPROVED', licenseFileId: fileId, recordVersion: '2', updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: true, expiringSoon: true, reason: 'ELIGIBLE', licenseExpiresOn: '2026-10-22' } }
|
||||
const ok = (data: unknown) => ({ code: '200', message: '成功', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('driver APIs', () => {
|
||||
it('preserves long IDs and date-only values, uses one page request, and normalizes versions', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [record], page: '1', size: '20', total: '1', pages: '1', summary: { total: '1', owned: '1', dispatched: '0', expiringSoon: '1', expired: '0' } })).mockResolvedValueOnce(ok(record))
|
||||
const result = await listDrivers({ page: 1, size: 20, active: false })
|
||||
expect(result).toMatchObject({ page: 1, total: 1, records: [{ id, licenseFileId: fileId, recordVersion: 2, details }] })
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
await getDriver(id)
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, base, { params: { page: 1, size: 20, active: false } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, `${base}/${id}`, { params: { expectedEnd: undefined } })
|
||||
})
|
||||
|
||||
it('sends current versions to edits, reviews, status changes, and resubmission', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok(record))
|
||||
vi.mocked(http.put).mockResolvedValue(ok(record))
|
||||
vi.mocked(http.patch).mockResolvedValue(ok(record))
|
||||
await createDriver(details)
|
||||
await updateDriver(id, 2, details)
|
||||
await reviewDriver(id, 3, 'REJECTED', '附件不清楚')
|
||||
await changeDriverStatus(id, 4, false, '暂停驾驶')
|
||||
await submitDriver(id, 5)
|
||||
expect(http.post).toHaveBeenCalledWith(base, details)
|
||||
expect(http.put).toHaveBeenCalledWith(`${base}/${id}`, { recordVersion: 2, details })
|
||||
expect(http.post).toHaveBeenCalledWith(`${base}/${id}/review`, { recordVersion: 3, decision: 'REJECTED', reason: '附件不清楚' })
|
||||
expect(http.patch).toHaveBeenCalledWith(`${base}/${id}/status`, { recordVersion: 4, active: false, reason: '暂停驾驶' })
|
||||
expect(http.post).toHaveBeenCalledWith(`${base}/${id}/submit`, { recordVersion: 5 })
|
||||
})
|
||||
|
||||
it('uploads a real multipart file with the version and leaves multipart boundary to the browser', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok(record))
|
||||
const file = new File(['%PDF-1.7'], 'license.pdf', { type: 'application/pdf' })
|
||||
await uploadDriverLicense(id, 2, file)
|
||||
const [url, body] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(url).toBe(`${base}/${id}/license`)
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect((body as FormData).get('recordVersion')).toBe('2')
|
||||
expect((body as FormData).get('file')).toBe(file)
|
||||
})
|
||||
|
||||
it('takes task qualification from server and keeps history snapshots distinct', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(ok(record.qualification)).mockResolvedValueOnce(ok({ page: '1', size: '20', total: '1', pages: '1', records: [{ recordVersion: '1', action: 'UPLOAD_LICENSE', snapshot: { ...record, recordVersion: '1', reviewStatus: 'PENDING' } }] }))
|
||||
const expectedEnd = '2026-10-20T18:00:00Z'
|
||||
expect(await getDriverQualification(id, expectedEnd)).toEqual(record.qualification)
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, `${base}/${id}/qualification`, { params: { expectedEnd } })
|
||||
expect((await listDriverHistory(id)).records[0]).toMatchObject({ recordVersion: 1, snapshot: { recordVersion: 1, reviewStatus: 'PENDING' } })
|
||||
})
|
||||
|
||||
it('downloads only real supported blobs and rejects HTTP200 JSON business failures', async () => {
|
||||
const pdf = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
|
||||
vi.mocked(http.get).mockResolvedValueOnce(pdf).mockResolvedValueOnce({ code: 'A0404', message: '文件未关联', requestId: 'test-request' }).mockResolvedValueOnce(new Blob(['<html>'], { type: 'text/html' }))
|
||||
expect(await downloadDriverLicense(id, fileId)).toBe(pdf)
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, `${base}/${id}/license/${fileId}`, { responseType: 'blob' })
|
||||
await expect(downloadDriverLicense(id, fileId)).rejects.toMatchObject({ message: '文件未关联', requestId: 'test-request' })
|
||||
await expect(downloadDriverLicense(id, fileId)).rejects.toThrow('格式不受支持')
|
||||
})
|
||||
|
||||
it('does not turn a failed write into a success or round an unsafe record version', async () => {
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ code: 'A0409', message: '请刷新后重试' }).mockResolvedValueOnce(ok({ ...record, recordVersion: '9223372036854775807' }))
|
||||
await expect(reviewDriver(id, 1, 'APPROVED', null)).rejects.toBeInstanceOf(ApiResponseError)
|
||||
await expect(createDriver(details)).rejects.toThrow('无效的分页或版本信息')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import axios from 'axios'
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { DriverDetail, DriverDraft, DriverHistory, DriverQualification, DriverReviewStatus, DriverLicenseState, DriverSummary, DriverWarehouseOption } from '../driverTypes'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
const base = '/platform/v1/last-leg/fleet/drivers'
|
||||
type Numeric = string | number
|
||||
type DriverWire = Omit<DriverDetail, 'recordVersion'> & { recordVersion: Numeric }
|
||||
type HistoryWire = Omit<DriverHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: DriverWire }
|
||||
type PageWire<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
|
||||
export interface DriverQuery {
|
||||
page: number
|
||||
size: number
|
||||
keyword?: string
|
||||
active?: boolean
|
||||
reviewStatus?: DriverReviewStatus
|
||||
ownership?: DriverDraft['ownership']
|
||||
region?: DriverDraft['region']
|
||||
licenseState?: DriverLicenseState
|
||||
expectedEnd?: string
|
||||
}
|
||||
|
||||
function numeric(value: Numeric): number {
|
||||
const result = Number(value)
|
||||
if (!Number.isSafeInteger(result) || result < 0) throw new Error('司机接口返回了无效的分页或版本信息')
|
||||
return result
|
||||
}
|
||||
const driver = (value: DriverWire): DriverDetail => ({ ...value, recordVersion: numeric(value.recordVersion) })
|
||||
function page<T, W>(value: PageWire<W>, normalize: (record: W) => T): PageResult<T> {
|
||||
return { records: value.records.map(normalize), page: numeric(value.page), size: numeric(value.size), total: numeric(value.total), pages: numeric(value.pages) }
|
||||
}
|
||||
const path = (id: string) => `${base}/${encodeURIComponent(id)}`
|
||||
|
||||
type DriverPageWire = PageWire<DriverWire> & { summary: { [K in keyof DriverSummary]: Numeric } }
|
||||
export async function listDrivers(query: DriverQuery): Promise<PageResult<DriverDetail> & { summary: DriverSummary }> {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<DriverPageWire>, ApiEnvelope<DriverPageWire>>(base, { params: query }))
|
||||
return { ...page(result, driver), summary: { total: numeric(result.summary.total), owned: numeric(result.summary.owned), dispatched: numeric(result.summary.dispatched), expiringSoon: numeric(result.summary.expiringSoon), expired: numeric(result.summary.expired) } }
|
||||
}
|
||||
export async function listDriverWarehouses(region: DriverDraft['region']): Promise<DriverWarehouseOption[]> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<DriverWarehouseOption[]>, ApiEnvelope<DriverWarehouseOption[]>>(`${base}/warehouse-options`, { params: { region } }))
|
||||
}
|
||||
export async function getDriver(id: string, expectedEnd?: string): Promise<DriverDetail> {
|
||||
return driver(unwrapApiResponse(await http.get<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(path(id), { params: { expectedEnd } })))
|
||||
}
|
||||
export async function getDriverQualification(id: string, expectedEnd: string): Promise<DriverQualification> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<DriverQualification>, ApiEnvelope<DriverQualification>>(`${path(id)}/qualification`, { params: { expectedEnd } }))
|
||||
}
|
||||
export async function listDriverHistory(id: string, currentPage = 1, size = 20): Promise<PageResult<DriverHistory>> {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<HistoryWire>>, ApiEnvelope<PageWire<HistoryWire>>>(`${path(id)}/history`, { params: { page: currentPage, size } }))
|
||||
return page(result, value => ({ ...value, recordVersion: numeric(value.recordVersion), snapshot: driver(value.snapshot) }))
|
||||
}
|
||||
export async function createDriver(details: DriverDraft): Promise<DriverDetail> {
|
||||
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(base, details)))
|
||||
}
|
||||
export async function updateDriver(id: string, recordVersion: number, details: DriverDraft): Promise<DriverDetail> {
|
||||
return driver(unwrapApiResponse(await http.put<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(path(id), { recordVersion, details })))
|
||||
}
|
||||
export async function changeDriverStatus(id: string, recordVersion: number, active: boolean, reason: string): Promise<DriverDetail> {
|
||||
return driver(unwrapApiResponse(await http.patch<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/status`, { recordVersion, active, reason })))
|
||||
}
|
||||
export async function reviewDriver(id: string, recordVersion: number, decision: 'APPROVED' | 'REJECTED', reason: string | null): Promise<DriverDetail> {
|
||||
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/review`, { recordVersion, decision, reason })))
|
||||
}
|
||||
export async function submitDriver(id: string, recordVersion: number): Promise<DriverDetail> {
|
||||
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/submit`, { recordVersion })))
|
||||
}
|
||||
export async function uploadDriverLicense(id: string, recordVersion: number, file: File): Promise<DriverDetail> {
|
||||
const form = new FormData()
|
||||
form.append('recordVersion', String(recordVersion))
|
||||
form.append('file', file)
|
||||
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/license`, form)))
|
||||
}
|
||||
|
||||
async function checkDownloadError(payload: unknown, status: number): Promise<void> {
|
||||
let value = payload
|
||||
if (payload instanceof Blob) {
|
||||
if (!payload.type.includes('json')) return
|
||||
try { value = JSON.parse(await payload.text()) as unknown }
|
||||
catch { throw new ApiResponseError('驾驶证接口返回了无法识别的错误响应', 'INVALID_DOWNLOAD_RESPONSE', status) }
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && 'code' in value) {
|
||||
const error = value as Partial<ApiEnvelope<unknown>>
|
||||
throw new ApiResponseError(error.message || '驾驶证读取失败', error.code || 'DOWNLOAD_FAILED', status, error.data, error.requestId, error.traceId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadDriverLicense(id: string, fileId: string): Promise<Blob> {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(`${path(id)}/license/${encodeURIComponent(fileId)}`, { responseType: 'blob' })
|
||||
await checkDownloadError(blob, 200)
|
||||
if (!(blob instanceof Blob) || blob.size === 0 || !['application/pdf', 'image/jpeg', 'image/png'].includes(blob.type)) {
|
||||
throw new Error('驾驶证文件为空或格式不受支持')
|
||||
}
|
||||
return blob
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) await checkDownloadError(error.response?.data, error.response?.status || 0)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listFleetSaleQuoteCandidates, fleetQuoteReference, type FleetSaleQuoteCriteria } from './fleetQuoteCandidates'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||
const criteria: FleetSaleQuoteCriteria = { customerId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', effectiveOn: '2026-10-03', priceBasis: 'PALLET', page: 1, size: 20 }
|
||||
const snapshot = { quoteNo: 'FQ-SOURCE', recordVersion: '90071992547409932', priceItem: 'LOOSE_DELIVERY', customerId: criteria.customerId, customerName: '委托客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '10', currency: 'USD', priceBasis: 'PALLET', quotedUnitPrice: '0.00', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: criteria.effectiveOn }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('keeps source identity/version and explicit zero, without constructing a booking write or total', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [snapshot], page: '1', size: '20', total: '1', pages: '1' } })
|
||||
const result = await listFleetSaleQuoteCandidates(criteria)
|
||||
expect(result.records[0]).toMatchObject({ recordVersion: snapshot.recordVersion, customerId: criteria.customerId, quotedUnitPrice: 0, distanceMiles: 10 })
|
||||
expect(fleetQuoteReference(result.records[0]!)).toEqual({ quoteNo: snapshot.quoteNo, recordVersion: snapshot.recordVersion, priceItem: 'LOOSE_DELIVERY' })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/quotes/sale-candidates', { params: criteria })
|
||||
expect(result.records[0]).not.toHaveProperty('agreedAmount')
|
||||
})
|
||||
it('does not turn an unknown price into an eligible zero-price candidate', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...snapshot, quotedUnitPrice: null }], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
await expect(listFleetSaleQuoteCandidates(criteria)).rejects.toThrow('金额格式无效')
|
||||
})
|
||||
it('surfaces permission errors instead of returning an empty successful list', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无报价读取权限', requestId: 'candidate-test-request' })
|
||||
await expect(listFleetSaleQuoteCandidates(criteria)).rejects.toMatchObject({ code: 'A0403', requestId: 'candidate-test-request' })
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
import { fleetMoney, fleetNumber, fleetPage, fleetVersion } from './fleetQuotes'
|
||||
|
||||
export interface FleetSaleQuoteCriteria {
|
||||
customerId: string; originCity: string; destinationCode: string; currency: 'USD' | 'CNY'
|
||||
effectiveOn: string; priceBasis: 'ORDER' | 'PALLET'; page?: number; size?: number
|
||||
}
|
||||
export interface FleetQuoteReference { quoteNo: string; recordVersion: string; priceItem: 'FULL_DELIVERY' | 'LOOSE_DELIVERY' }
|
||||
export interface FleetQuoteSourceSnapshot extends FleetQuoteReference {
|
||||
customerId: string; customerName: string; originCity: string; destinationCode: string; distanceMiles: number
|
||||
currency: 'USD' | 'CNY'; priceBasis: 'ORDER' | 'PALLET'; quotedUnitPrice: number
|
||||
validFrom: string; validTo: string; effectiveOn: string
|
||||
}
|
||||
type Numeric = string | number
|
||||
export type WireSnapshot = Omit<FleetQuoteSourceSnapshot, 'recordVersion' | 'distanceMiles' | 'quotedUnitPrice'> & {
|
||||
recordVersion: Numeric; distanceMiles: Numeric; quotedUnitPrice: Numeric
|
||||
}
|
||||
export type WireQuotePage = Omit<PageResult<WireSnapshot>, 'page' | 'size' | 'total' | 'pages'> & { page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
export const normalizeFleetQuoteSource = (row: WireSnapshot): FleetQuoteSourceSnapshot => ({ ...row, recordVersion: fleetVersion(row.recordVersion), distanceMiles: fleetNumber(row.distanceMiles), quotedUnitPrice: fleetMoney(row.quotedUnitPrice) })
|
||||
export const normalizeFleetQuotePage = (value: WireQuotePage): PageResult<FleetQuoteSourceSnapshot> => fleetPage({ ...value, records: value.records.map(normalizeFleetQuoteSource) })
|
||||
|
||||
/** Only a candidate read: the booking writer must revalidate the reference and freeze its own source snapshot. */
|
||||
export async function listFleetSaleQuoteCandidates(criteria: FleetSaleQuoteCriteria): Promise<PageResult<FleetQuoteSourceSnapshot>> {
|
||||
const response = unwrapApiResponse(await http.get<ApiEnvelope<WireQuotePage>, ApiEnvelope<WireQuotePage>>('/platform/v1/last-leg/fleet/quotes/sale-candidates', { params: criteria }))
|
||||
return normalizeFleetQuotePage(response)
|
||||
}
|
||||
export function fleetQuoteReference(source: FleetQuoteSourceSnapshot): FleetQuoteReference {
|
||||
return { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http, ApiResponseError } from '@/shared/api/http'
|
||||
import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetCsv, fleetMoney, fleetVersion, isFleetCommandRejected, type FleetQuoteDraft } from './fleetQuotes'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
const details: FleetQuoteDraft = { kind: 'SALE', partyId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', distanceMiles: 10, selfPickupPrice: 0, fullDeliveryPrice: null, looseDeliveryPrice: 2, validFrom: '2026-01-01', validTo: '2026-12-31', remark: null }
|
||||
const quote = { quoteNo: 'FQ-ONE', details: { ...details, selfPickupPrice: '0', looseDeliveryPrice: '2', distanceMiles: '10' }, partyName: '委托企业', status: 'ACTIVE', recordVersion: '90071992547409931', updatedAt: '2026-10-01T00:00:00Z' }
|
||||
const ok = (data: unknown) => ({ code: '200', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('preserves long IDs and versions, explicit zero and absent prices', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ records: [quote], total: '1', size: '20', page: '1', pages: '1' }))
|
||||
const result = await listFleetQuotes({ kind: 'SALE' })
|
||||
expect(result.records[0]).toMatchObject({ details, recordVersion: '90071992547409931' })
|
||||
expect(result.total).toBe(1)
|
||||
vi.mocked(http.put).mockResolvedValue(ok(quote)); await updateFleetQuote(result.records[0]!, details)
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/quotes/FQ-ONE', { recordVersion: '90071992547409931', details })
|
||||
})
|
||||
it('sends stable creation and batch keys without converting business references', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok(quote)); await createFleetQuote('stable-key', details)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/quotes', { idempotencyKey: 'stable-key', details })
|
||||
vi.mocked(http.post).mockResolvedValue(ok({ importedRows: 1 })); await importFleetQuotes('batch-key', [details])
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/quotes/import', { idempotencyKey: 'batch-key', rows: [details] })
|
||||
})
|
||||
it('does not download a business error JSON as a CSV file', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(new TextEncoder().encode('{"code":"CONFLICT","message":"缩小范围","requestId":"r1"}').buffer)
|
||||
await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '缩小范围', requestId: 'r1' })
|
||||
})
|
||||
it('preserves diagnostics from a non-200 export response', async () => {
|
||||
vi.mocked(http.get).mockRejectedValue({ isAxiosError: true, response: { status: 403, data: new TextEncoder().encode('{"code":"403","message":"无导出权限","traceId":"t1"}').buffer } })
|
||||
await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '无导出权限', status: 403, traceId: 't1' })
|
||||
})
|
||||
it('rejects lossy money and numeric versions instead of rounding them', () => {
|
||||
expect(() => fleetMoney('99999999999999999.01')).toThrow('精确展示')
|
||||
expect(() => fleetVersion(9007199254740992)).toThrow('版本无效')
|
||||
expect(fleetVersion('90071992547409931')).toBe('90071992547409931')
|
||||
})
|
||||
it('recognizes deployed A04xx business rejection without treating S0500 or network failure as a definite abort', () => {
|
||||
for (const code of ['A0400', 'A0409', 'A0422']) expect(isFleetCommandRejected(new ApiResponseError('业务拒绝', code, 200))).toBe(true)
|
||||
expect(isFleetCommandRejected(new ApiResponseError('服务器结果不明', 'S0500', 200))).toBe(false)
|
||||
expect(isFleetCommandRejected(new Error('Network error'))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import axios from 'axios'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
export type FleetQuoteKind = 'PURCHASE' | 'SALE'
|
||||
export interface FleetQuoteDraft {
|
||||
kind: FleetQuoteKind; partyId: string; originCity: string; destinationCode: string; distanceMiles: number
|
||||
currency: 'USD' | 'CNY'; selfPickupPrice: number | null; fullDeliveryPrice: number | null; looseDeliveryPrice: number | null
|
||||
validFrom: string; validTo: string; remark: string | null
|
||||
}
|
||||
export interface FleetQuote { quoteNo: string; details: FleetQuoteDraft; partyName: string; status: 'ACTIVE' | 'INACTIVE' | 'DELETED'; recordVersion: string; updatedAt: string }
|
||||
export interface FleetQuoteQuery { page?: number; size?: number; keyword?: string; kind?: FleetQuoteKind; status?: string; effectiveOn?: string }
|
||||
type Numeric = string | number
|
||||
type WireQuote = Omit<FleetQuote, 'details' | 'recordVersion'> & { recordVersion: Numeric; details: Omit<FleetQuoteDraft, 'distanceMiles' | 'selfPickupPrice' | 'fullDeliveryPrice' | 'looseDeliveryPrice'> & { distanceMiles: Numeric; selfPickupPrice: Numeric | null; fullDeliveryPrice: Numeric | null; looseDeliveryPrice: Numeric | null } }
|
||||
export function fleetNumber(value: Numeric): number {
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || String(value).trim() === '' || !Number.isFinite(Number(value)) || Number(value) < 0) throw new Error('接口返回了无效数值')
|
||||
return Number(value)
|
||||
}
|
||||
export function fleetMoney(value: Numeric): number {
|
||||
const text = String(value)
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new Error('金额格式无效')
|
||||
const [whole, fraction = ''] = text.split('.')
|
||||
const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0'))
|
||||
if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('金额超出精确展示范围')
|
||||
return Number(value)
|
||||
}
|
||||
export function fleetVersion(value: Numeric): string {
|
||||
if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('接口版本无效')
|
||||
return String(value)
|
||||
}
|
||||
export function isFleetCommandRejected(error: unknown): boolean {
|
||||
return error instanceof ApiResponseError && ((error.status != null && error.status >= 400 && error.status < 500)
|
||||
|| /^A04\d\d$/.test(error.code) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(error.code))
|
||||
}
|
||||
export function fleetPage<T>(value: { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }): PageResult<T> {
|
||||
const integer = (n: Numeric) => { const result = fleetNumber(n); if (!Number.isSafeInteger(result)) throw new Error('接口分页超出有效范围'); return result }
|
||||
return { ...value, page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) }
|
||||
}
|
||||
const base = '/platform/v1/last-leg/fleet/quotes'
|
||||
const path = (no: string) => `${base}/${encodeURIComponent(no)}`
|
||||
const quote = (v: WireQuote): FleetQuote => ({ ...v, recordVersion: fleetVersion(v.recordVersion), details: { ...v.details, partyId: String(v.details.partyId), distanceMiles: fleetNumber(v.details.distanceMiles),
|
||||
selfPickupPrice: v.details.selfPickupPrice == null ? null : fleetNumber(v.details.selfPickupPrice), fullDeliveryPrice: v.details.fullDeliveryPrice == null ? null : fleetNumber(v.details.fullDeliveryPrice), looseDeliveryPrice: v.details.looseDeliveryPrice == null ? null : fleetNumber(v.details.looseDeliveryPrice) } })
|
||||
export async function listFleetQuotes(query: FleetQuoteQuery) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageResult<WireQuote>>, ApiEnvelope<PageResult<WireQuote>>>(base, { params: query }))
|
||||
return fleetPage({ ...result, records: result.records.map(quote) })
|
||||
}
|
||||
export async function createFleetQuote(idempotencyKey: string, details: FleetQuoteDraft) { return quote(unwrapApiResponse(await http.post<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(base, { idempotencyKey, details }))) }
|
||||
export async function updateFleetQuote(value: FleetQuote, details: FleetQuoteDraft) { return quote(unwrapApiResponse(await http.put<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(path(value.quoteNo), { recordVersion: value.recordVersion, details }))) }
|
||||
export async function setFleetQuoteStatus(value: FleetQuote, status: string) { return quote(unwrapApiResponse(await http.patch<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(`${path(value.quoteNo)}/status`, { recordVersion: value.recordVersion, status }))) }
|
||||
export async function deleteFleetQuote(value: FleetQuote) { return quote(unwrapApiResponse(await http.delete<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(path(value.quoteNo), { params: { recordVersion: value.recordVersion } }))) }
|
||||
export async function importFleetQuotes(idempotencyKey: string, rows: FleetQuoteDraft[]) { return unwrapApiResponse(await http.post<ApiEnvelope<{ importedRows: number }>, ApiEnvelope<{ importedRows: number }>>(`${base}/import`, { idempotencyKey, rows })) }
|
||||
export async function fleetQuoteCustomers(keyword: string) { return unwrapApiResponse(await http.get<ApiEnvelope<{ id: string; name: string; code: string }[]>, ApiEnvelope<{ id: string; name: string; code: string }[]>>(`${base}/customer-options`, { params: { keyword } })) }
|
||||
export async function fleetQuoteSuppliers(keyword: string) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageResult<{ id: string; name: string }>>, ApiEnvelope<PageResult<{ id: string; name: string }>>>('/platform/v1/suppliers', { params: { page: 1, size: 30, keyword, status: 'ACTIVE', category: 'TRUCKING', truckingScope: 'OVERSEAS' } }))
|
||||
return result.records.map(row => ({ ...row, id: String(row.id) }))
|
||||
}
|
||||
export async function fleetCsv(path: string, params: object): Promise<Blob> {
|
||||
function check(bytes: ArrayBuffer, status: number) {
|
||||
const text = new TextDecoder().decode(bytes)
|
||||
if (!/^\s*[{[]/.test(text)) return
|
||||
let error: ApiEnvelope<unknown>
|
||||
try { error = JSON.parse(text) as ApiEnvelope<unknown> } catch { throw new ApiResponseError('导出接口返回了无效响应', 'INVALID_EXPORT_RESPONSE', status) }
|
||||
throw new ApiResponseError(error.message || '导出失败', error.code || 'EXPORT_FAILED', status, error.data, error.requestId, error.traceId)
|
||||
}
|
||||
try {
|
||||
const bytes = await http.get<ArrayBuffer, ArrayBuffer>(path, { params, responseType: 'arraybuffer' }); check(bytes, 200)
|
||||
return new Blob([bytes], { type: 'text/csv;charset=utf-8' })
|
||||
} catch (e) { if (axios.isAxiosError(e) && Object.prototype.toString.call(e.response?.data) === '[object ArrayBuffer]') check(e.response!.data as ArrayBuffer, e.response!.status); throw e }
|
||||
}
|
||||
export const exportFleetQuotes = (query: FleetQuoteQuery) => fleetCsv(`${base}/export`, query)
|
||||
export function saveFleetCsv(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
@@ -23,7 +23,7 @@ const quoteDetail = {
|
||||
createdBy: 'admin', createdAt: '2026-08-24T09:00:00',
|
||||
}
|
||||
const exchangeRate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: '7.12345678', status: 'ACTIVE', recordVersion: '3', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
|
||||
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
|
||||
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('overseas master data API', () => {
|
||||
expect(result.records[0]).toMatchObject({ id: longId, fuelConsumptionPer100Km: 12.5, mileageKm: 101, recordVersion: 4 })
|
||||
expect(detail.id).toBe(longId)
|
||||
|
||||
const editable = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null }
|
||||
const editable = { plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null }
|
||||
vi.mocked(http.post).mockResolvedValue(ok(vehicle))
|
||||
vi.mocked(http.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' }))
|
||||
vi.mocked(http.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' }))
|
||||
|
||||
@@ -17,7 +17,7 @@ type QuoteDetailWire = Omit<OverseasWarehouseQuoteDetail, 'summary' | 'warehouse
|
||||
type ExchangeRateWire = Omit<MonthlyExchangeRate, 'usdToCnyAverage' | 'recordVersion'> & { usdToCnyAverage: Numeric; recordVersion: Numeric }
|
||||
type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }
|
||||
type ExchangeRateSummaryWire = Omit<ExchangeRateSummary, 'latestUsdToCnyAverage'> & { latestUsdToCnyAverage: Numeric | null }
|
||||
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric }
|
||||
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg' | 'maxVolumeCbm'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null }
|
||||
|
||||
function page<T, W>(value: PagePayload<W>, normalize: (record: W) => T): PageResult<T> {
|
||||
return { ...value, records: value.records.map(normalize), page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) }
|
||||
@@ -32,7 +32,7 @@ const normalizeFee = (value: FeeWire): OverseasWarehouseFee => ({ ...value, unit
|
||||
const normalizeTransitPrice = (value: TransitPriceWire): OverseasTransitPrice => ({ ...value, estimatedMiles: nullableNumeric(value.estimatedMiles), price: numeric(value.price), minimumCharge: nullableNumeric(value.minimumCharge) })
|
||||
const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDetail => ({ ...value, summary: normalizeQuote(value.summary), warehouseFees: value.warehouseFees.map(normalizeFee), transitPrices: value.transitPrices.map(normalizeTransitPrice) })
|
||||
const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) })
|
||||
const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion) })
|
||||
const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion), maxPalletSpaces: nullableNumeric(value.maxPalletSpaces), maxLoadKg: nullableNumeric(value.maxLoadKg), maxVolumeCbm: nullableNumeric(value.maxVolumeCbm) })
|
||||
|
||||
type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listTripBookingQuoteCandidates, normalizeBooking, type WireBooking } from './tripBookings'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||
const source = { quoteNo: 'FQ-1', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY' as const, customerId: '9', customerName: '客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '30', currency: 'USD' as const, priceBasis: 'PALLET' as const, quotedUnitPrice: '0.00', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-03' }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('uses trip-scoped candidate criteria without accepting browser-computed city or date', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [source], page: '1', size: '20', total: '1', pages: '1' } })
|
||||
const criteria = { customerId: '9', destinationKey: 'FBA:ONT8', currency: 'USD' as const, priceBasis: 'PALLET' as const }
|
||||
const value = await listTripBookingQuoteCandidates('TR-1', criteria)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-1/bookings/quote-candidates', { params: criteria })
|
||||
expect(value.records[0]).toMatchObject({ recordVersion: source.recordVersion, quotedUnitPrice: 0 })
|
||||
})
|
||||
it('normalizes quote source without overwriting negotiated price and handles manual absence', () => {
|
||||
const row: WireBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户', status: 'DRAFT', destinationLabel: 'ONT8', agreedAmount: '25.00', recordVersion: '1', updatedAt: '', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: source,
|
||||
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: '1', palletSpaces: '2', weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: '12.50', currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const value = normalizeBooking(row)
|
||||
expect(value.quoteSource).toMatchObject({ quotedUnitPrice: 0, recordVersion: source.recordVersion })
|
||||
expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25)
|
||||
expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull()
|
||||
expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } })
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeFleetQuoteSource, normalizeFleetQuotePage, type WireSnapshot, type WireQuotePage } from './fleetQuoteCandidates'
|
||||
import type { FleetCustomerOption, TripBooking, TripBookingCargo, TripBookingCreate, TripBookingUpdate, TripBookingCommand, TripBookingResult, TripBookingList, TripIncomingEvidence, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind, TripAcceptedReceipt } from '../bookingTypes'
|
||||
const base = '/platform/v1/last-leg/fleet/trips'
|
||||
const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings`
|
||||
type N = number | string
|
||||
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
|
||||
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
|
||||
type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
|
||||
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
|
||||
type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
|
||||
const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result }
|
||||
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
|
||||
const nullable = (value: N | null) => value == null ? null : number(value)
|
||||
const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
|
||||
const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
|
||||
export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
|
||||
export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) {
|
||||
return normalizeFleetQuotePage(unwrapApiResponse(await http.get<ApiEnvelope<WireQuotePage>, ApiEnvelope<WireQuotePage>>(`${path(tripNo)}/quote-candidates`, { params: criteria })))
|
||||
}
|
||||
export async function listFleetCustomers(keyword?: string): Promise<FleetCustomerOption[]> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<FleetCustomerOption[]>, ApiEnvelope<FleetCustomerOption[]>>(`${base}/customer-options`, { params: { keyword } }))
|
||||
}
|
||||
export async function listTripBookings(tripNo: string): Promise<TripBookingList> {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; records: WireBooking[] }>, ApiEnvelope<{ tripRecordVersion: N; records: WireBooking[] }>>(path(tripNo)))
|
||||
return { tripRecordVersion: integer(value.tripRecordVersion), records: value.records.map(normalizeBooking) }
|
||||
}
|
||||
export async function createTripBooking(tripNo: string, body: TripBookingCreate): Promise<TripBookingResult> {
|
||||
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(path(tripNo), body)))
|
||||
}
|
||||
export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise<TripBookingResult> {
|
||||
return result(unwrapApiResponse(await http.put<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
|
||||
}
|
||||
async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise<TripBookingResult> {
|
||||
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body)))
|
||||
}
|
||||
export const confirmTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'confirm', body)
|
||||
export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'cancel', body)
|
||||
|
||||
const bookingPath = (tripNo: string, bookingNo: string) => `${path(tripNo)}/${encodeURIComponent(bookingNo)}`
|
||||
export async function getTripIncoming(tripNo: string, bookingNo: string): Promise<TripIncomingPreview> {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>, ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>>(`${bookingPath(tripNo, bookingNo)}/incoming`))
|
||||
return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : incoming(value.incoming) }
|
||||
}
|
||||
async function incomingCommand(tripNo: string, bookingNo: string, action: string, body: TripIncomingStart | TripIncomingAccept | TripBookingBind): Promise<TripBookingResult> {
|
||||
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${bookingPath(tripNo, bookingNo)}/${action}`, body)))
|
||||
}
|
||||
export const startTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingStart) => incomingCommand(tripNo, bookingNo, 'incoming', body)
|
||||
export const acceptTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingAccept) => incomingCommand(tripNo, bookingNo, 'receipt-confirmation', body)
|
||||
export const bindTripBookingDispatch = (tripNo: string, bookingNo: string, body: TripBookingBind) => incomingCommand(tripNo, bookingNo, 'dispatch', body)
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listTripCosts, createTripCost, updateTripCost, listTripCostHistory } from './tripCosts'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
const details = { category: 'FUEL' as const, currency: 'USD' as const, amount: 12.5, incurredAt: '2026-01-01T00:00:00Z', remark: null }
|
||||
const wire = { costNo: 'TC-ONE', tripNo: 'TR-ONE', details: { ...details, amount: '12.50' }, recordVersion: '90071992547409931', updatedAt: details.incurredAt }
|
||||
const summary = { totals: [{ currency: 'USD', directCost: '12.50', allocations: [] }], allocationBlockReason: '尚无实际发车装载记录' }
|
||||
const ok = (data: unknown) => ({ code: '200', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('normalizes numbers without turning missing allocation into zero or rounding long versions', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [wire], page: '1', size: '10', total: '1', pages: '1' }, summary }))
|
||||
const result = await listTripCosts('TR-ONE')
|
||||
expect(result.page.records[0]).toMatchObject({ details, recordVersion: wire.recordVersion })
|
||||
expect(result.summary).toMatchObject({ departureVersion: null, manifestHash: null, totals: [{ directCost: 12.5, ownCost: null, externalCost: null }] })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs', { params: { page: 1, size: 10 } })
|
||||
})
|
||||
it('preserves explicit zero and keeps currencies in separate server totals', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [
|
||||
{ currency: 'CNY', directCost: '0.00', ownCost: '0.00', externalCost: '0.00', allocations: [] },
|
||||
{ currency: 'USD', directCost: '2.00', allocations: [] }] } }))
|
||||
const result = await listTripCosts('TR-ONE')
|
||||
expect(result.summary.totals.map(r => [r.currency, r.directCost])).toEqual([['CNY', 0], ['USD', 2]])
|
||||
expect(result.summary.totals[0]?.ownCost).toBe(0)
|
||||
})
|
||||
it('preserves creation key, exact correction version and source-specific history path', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok(wire)); vi.mocked(http.put).mockResolvedValue(ok(wire))
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ records: [{ recordVersion: wire.recordVersion, action: 'UPDATE', reason: '核对', occurredAt: details.incurredAt, snapshot: { cost: wire, summary } }], page: 1, size: 1, total: 1, pages: 1 }))
|
||||
await createTripCost('TR-ONE', 'fixed-key', details)
|
||||
await updateTripCost('TR-ONE', wire, details)
|
||||
const history = await listTripCostHistory('TR-ONE', 'TC-ONE', 1, 1)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs', { idempotencyKey: 'fixed-key', details })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs/TC-ONE', { recordVersion: wire.recordVersion, details, reason: '更正本趟直接成本' })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs/TC-ONE/history', { params: { page: 1, size: 1 } })
|
||||
expect(history.records[0]?.snapshot.cost.recordVersion).toBe(wire.recordVersion)
|
||||
})
|
||||
it('rejects invalid amounts instead of reporting successful zero costs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [{ currency: 'USD', directCost: null, allocations: [] }] } }))
|
||||
await expect(listTripCosts('TR-ONE')).rejects.toThrow('无效金额')
|
||||
})
|
||||
it.each([' ', true, '0.001', 'NaN', '90071992547409.92'])('rejects malformed or imprecise money %s', async amount => {
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [{ currency: 'USD', directCost: amount, allocations: [] }] } }))
|
||||
await expect(listTripCosts('TR-ONE')).rejects.toThrow(/无效金额|精确展示范围/)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
export type CostCategory = 'FUEL' | 'DRIVER' | 'TOLL' | 'PARKING' | 'OTHER'
|
||||
export type CostCurrency = 'CNY' | 'USD'
|
||||
export interface TripCostDraft { category: CostCategory; currency: CostCurrency; amount: number; incurredAt: string; remark: string | null }
|
||||
export interface TripCost { costNo: string; tripNo: string; details: TripCostDraft; recordVersion: string; updatedAt: string }
|
||||
export interface CostAllocation { businessNo: string; sourceType: 'OWN' | 'EXTERNAL'; dispatchNo: string; palletSpaces: number; amount: number }
|
||||
export interface CostTotal { currency: CostCurrency; directCost: number; allocations: CostAllocation[]; ownCost: number | null; externalCost: number | null }
|
||||
export interface CostSummary { departureVersion: string | null; manifestHash: string | null; allocationMethod: string | null; allocationBlockReason: string | null; totals: CostTotal[] }
|
||||
export interface TripCosts { page: PageResult<TripCost>; summary: CostSummary }
|
||||
export interface TripCostHistory { recordVersion: string; action: string; reason: string; occurredAt: string; snapshot: { cost: TripCost; summary: CostSummary } }
|
||||
type Numeric = string | number
|
||||
type CostWire = Omit<TripCost, 'recordVersion' | 'details'> & { recordVersion: Numeric; details: Omit<TripCostDraft, 'amount' | 'remark'> & { amount: Numeric; remark?: string | null } }
|
||||
type SummaryWire = Omit<CostSummary, 'departureVersion' | 'totals'> & { departureVersion?: Numeric | null; totals: Array<Omit<CostTotal, 'directCost' | 'ownCost' | 'externalCost' | 'allocations'> & {
|
||||
directCost: Numeric; ownCost?: Numeric | null; externalCost?: Numeric | null; allocations: Array<Omit<CostAllocation, 'amount' | 'palletSpaces'> & { amount: Numeric; palletSpaces: Numeric }>
|
||||
}> }
|
||||
type PageWire<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
const path = (tripNo: string) => `/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripNo)}/costs`
|
||||
function decimal(value: Numeric): number {
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('车次成本接口返回了无效金额')
|
||||
const [whole, fraction = ''] = String(value).split('.')
|
||||
const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0'))
|
||||
if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('车次成本金额超出精确展示范围')
|
||||
return Number(value)
|
||||
}
|
||||
function integer(value: Numeric): number {
|
||||
const number = Number(value)
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+$/.test(String(value))) throw new Error('车次成本接口返回了无效分页或托位')
|
||||
if (!Number.isSafeInteger(number)) throw new Error('车次成本接口返回了无效分页或托位')
|
||||
return number
|
||||
}
|
||||
function version(value: Numeric): string {
|
||||
if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('车次成本版本无效')
|
||||
return String(value)
|
||||
}
|
||||
function cost(value: CostWire): TripCost { return { ...value, recordVersion: version(value.recordVersion), details: { ...value.details, amount: decimal(value.details.amount), remark: value.details.remark ?? null } } }
|
||||
function summary(value: SummaryWire): CostSummary {
|
||||
return { departureVersion: value.departureVersion == null ? null : version(value.departureVersion), manifestHash: value.manifestHash ?? null,
|
||||
allocationMethod: value.allocationMethod ?? null, allocationBlockReason: value.allocationBlockReason ?? null,
|
||||
totals: value.totals.map(row => ({ ...row, directCost: decimal(row.directCost), ownCost: row.ownCost == null ? null : decimal(row.ownCost),
|
||||
externalCost: row.externalCost == null ? null : decimal(row.externalCost),
|
||||
allocations: row.allocations.map(part => ({ ...part, amount: decimal(part.amount), palletSpaces: integer(part.palletSpaces) })) })) }
|
||||
}
|
||||
function page<T, R>(value: PageWire<T>, transform: (row: T) => R): PageResult<R> {
|
||||
return { records: value.records.map(transform), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) }
|
||||
}
|
||||
export async function listTripCosts(tripNo: string, currentPage = 1, size = 10): Promise<TripCosts> {
|
||||
type Wire = { page: PageWire<CostWire>; summary: SummaryWire }
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(tripNo), { params: { page: currentPage, size } }))
|
||||
return { page: page(result.page, cost), summary: summary(result.summary) }
|
||||
}
|
||||
export async function createTripCost(tripNo: string, idempotencyKey: string, details: TripCostDraft): Promise<TripCost> {
|
||||
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(path(tripNo), { idempotencyKey, details })))
|
||||
}
|
||||
export async function updateTripCost(tripNo: string, record: Pick<TripCost, 'costNo' | 'recordVersion'>, details: TripCostDraft, reason?: string): Promise<TripCost> {
|
||||
return cost(unwrapApiResponse(await http.put<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${path(tripNo)}/${encodeURIComponent(record.costNo)}`, { recordVersion: record.recordVersion, details, reason: reason?.trim() || '更正本趟直接成本' })))
|
||||
}
|
||||
export async function listTripCostHistory(tripNo: string, costNo: string, currentPage = 1, size = 10): Promise<PageResult<TripCostHistory>> {
|
||||
type HistoryWire = Omit<TripCostHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: { cost: CostWire; summary: SummaryWire } }
|
||||
type Wire = PageWire<HistoryWire>
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${path(tripNo)}/${encodeURIComponent(costNo)}/history`, { params: { page: currentPage, size } }))
|
||||
return page(result, row => ({ ...row, recordVersion: version(row.recordVersion), snapshot: { cost: cost(row.snapshot.cost), summary: summary(row.snapshot.summary) } }))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listTripHandovers, previewTripHandover, confirmTripHandover, releaseHandoverResource, handoverVersion } from './tripHandovers'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('encodes trip scope and normalizes pagination without per-row requests', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: '1', size: '10', total: '0', pages: '0' } })
|
||||
expect(await listTripHandovers('TR/ONE')).toEqual({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR%2FONE/handovers', { params: { page: 1, size: 10 } })
|
||||
})
|
||||
it('keeps preview separate from the exact immutable confirmation payload', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} })
|
||||
const selection = { recordVersion: 7, vehicleId: '90071992547409931', driverId: '90071992547409932', handedOverAt: '2026-10-03T01:00:00Z', expectedDriverEndAt: '2026-10-04T01:00:00Z' }
|
||||
await previewTripHandover('TR-ONE', selection)
|
||||
const command = { selection, cargoHash: 'a'.repeat(64), location: '交接点', idempotencyKey: 'original-key' }
|
||||
await confirmTripHandover('TR-ONE', command)
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/trips/TR-ONE/handovers/preview', selection)
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/trips/TR-ONE/handovers', command)
|
||||
})
|
||||
it('ends only the selected old resource within its handover and trip', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} })
|
||||
const command = { recordVersion: 0, resourceKind: 'DRIVER' as const, actualEndAt: '2026-10-03T02:00:00Z', location: '司机任务结束点', idempotencyKey: 'end-original' }
|
||||
await releaseHandoverResource('TR-ONE', 'HO/ONE', command)
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-ONE/handovers/HO%2FONE/resources/release', command)
|
||||
})
|
||||
it('refuses blank fractional or unsafe versions', () => {
|
||||
for (const value of ['', ' ', '1.5', '90071992547409931']) expect(() => handoverVersion(value)).toThrow()
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
export interface HandoverSelection { recordVersion: number; vehicleId: string; driverId: string; expectedDriverEndAt: string; handedOverAt: string }
|
||||
export interface HandoverCommand { selection: HandoverSelection; cargoHash: string; location: string; reason?: string; idempotencyKey: string }
|
||||
export interface HandoverRelease { recordVersion: number; resourceKind: 'VEHICLE' | 'DRIVER'; actualEndAt: string; location: string; vehicleStatus?: string; idempotencyKey: string }
|
||||
export interface HandoverResource { vehicleId: string; vehiclePlate: string | null; driverId: string; driverName: string | null; driverPhone: string | null }
|
||||
type Numeric = number | string
|
||||
export interface HandoverCargo { customerId: string; dispatchNo: string; itemId: string; palletId: string; destinationKey: string; packages: Numeric }
|
||||
export interface HandoverDelivery { customerId: string; dispatchNo: string; deliveryNo: string; destinationKey: string; packages: Numeric; terminalAt: string | null }
|
||||
export interface HandoverLoad { palletSpaces: Numeric; weightKg: Numeric; volumeCbm: Numeric }
|
||||
export interface HandoverInspection { remainingCargo: HandoverCargo[]; completedDeliveries: HandoverDelivery[]; remainingPalletIds: string[]; occupied: HandoverLoad }
|
||||
export interface HandoverPreview { tripNo: string; recordVersion: Numeric; cargoHash: string; previous: HandoverResource; replacement: HandoverResource; capacity: HandoverLoad; cargo: HandoverInspection; driverQualification: { canDispatch: boolean; expiringSoon: boolean; reason: string; licenseExpiresOn: string | null } }
|
||||
export interface HandoverEnded { actualEndAt: string; location: string; vehicleStatus: string | null }
|
||||
export interface HandoverRecord {
|
||||
handoverNo: string; tripNo: string; recordVersion: Numeric
|
||||
snapshot: { previous: HandoverResource; replacement: HandoverResource; handedOverAt: string; location: string; reason: string; expectedDriverEndAt: string; capacity: HandoverLoad; cargo: HandoverInspection; recordedAt: string }
|
||||
vehicleRelease: HandoverEnded | null; driverRelease: HandoverEnded | null
|
||||
}
|
||||
const path = (tripNo: string) => `/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripNo)}/handovers`
|
||||
export function handoverNumber(value: Numeric): number {
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error('交接接口缺少数值')
|
||||
const result = Number(value)
|
||||
if (!Number.isFinite(result) || result < 0) throw new Error('交接接口数值无效')
|
||||
return result
|
||||
}
|
||||
export function handoverVersion(value: Numeric): number {
|
||||
const result = handoverNumber(value)
|
||||
if (!Number.isSafeInteger(result)) throw new Error('交接接口版本或数量无效')
|
||||
return result
|
||||
}
|
||||
export async function listTripHandovers(tripNo: string, page = 1, size = 10): Promise<PageResult<HandoverRecord>> {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: HandoverRecord[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }>, ApiEnvelope<{ records: HandoverRecord[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }>>(path(tripNo), { params: { page, size } }))
|
||||
return { records: result.records, page: handoverVersion(result.page), size: handoverVersion(result.size), total: handoverVersion(result.total), pages: handoverVersion(result.pages) }
|
||||
}
|
||||
export async function previewTripHandover(tripNo: string, selection: HandoverSelection): Promise<HandoverPreview> {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverPreview>, ApiEnvelope<HandoverPreview>>(`${path(tripNo)}/preview`, selection))
|
||||
}
|
||||
export async function confirmTripHandover(tripNo: string, command: HandoverCommand): Promise<HandoverRecord> {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverRecord>, ApiEnvelope<HandoverRecord>>(path(tripNo), command))
|
||||
}
|
||||
export async function releaseHandoverResource(tripNo: string, handoverNo: string, command: HandoverRelease): Promise<HandoverRecord> {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverRecord>, ApiEnvelope<HandoverRecord>>(`${path(tripNo)}/${encodeURIComponent(handoverNo)}/resources/release`, command))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listTripOperations, getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, uploadTripOperationsEvidence, downloadTripOperationsEvidence } from './tripOperations'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
|
||||
it('uses one aggregate response, keeps currency groups and never invents finance/cost amounts', async () => {
|
||||
const counts = { physicalPallets: '1', palletSpaces: '2', ownPackages: '11', externalPackages: '4', externalAgreedAmounts: [{ currency: 'USD', amount: '0' }, { currency: 'CNY', amount: '10.25' }] }
|
||||
const totals = [{ currency: 'USD', directCost: '10.00', ownCost: null, externalCost: null }]
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { page: { records: [{ ...counts, tripNo: 'TR1', destinations: [{ key: 'FBA:ONT8', label: 'ONT8' }, { key: 'FBA:LAX9', label: 'LAX9' }], externalPalletRates: [], measured: { actualDistanceMiles: '0.0', actualFuelLitres: null, remark: null, evidenceFiles: [] }, financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { allocationBlockReason: '路线不同', totals } }], total: '20', page: '1', size: '1', pages: '20' }, summary: { ...counts, trips: '20', inTransit: '12', completed: '8', costs: { visible: true, unrecordedTrips: '1', unallocatedTrips: '1', totals } }, charts: { physicalPallets: [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-01', value: '1' }] }], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } } })
|
||||
const query = { from: '2026-10-01T00:00:00Z', to: '2026-10-03T00:00:00Z', sourceType: 'EXTERNAL' as const }
|
||||
const result = await listTripOperations(query)
|
||||
expect(result.summary.trips).toBe(20); expect(result.page.records).toHaveLength(1)
|
||||
expect(result.summary.externalAgreedAmounts).toEqual([{ currency: 'USD', amount: 0 }, { currency: 'CNY', amount: 10.25 }])
|
||||
expect(result.page.records[0]).toMatchObject({ financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { totals: [{ directCost: 10, ownCost: null, externalCost: null }] } })
|
||||
expect(result.summary.costs).toMatchObject({ unrecordedTrips: 1, unallocatedTrips: 1 })
|
||||
expect(result.page.records[0]?.measured).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null, evidenceFiles: [] })
|
||||
expect(result.charts.physicalPallets[0]?.points[0]?.value).toBe(1)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/operations', { params: query })
|
||||
})
|
||||
it('keeps measured versions and retries exact requests, without converting absent fuel into zero', async () => {
|
||||
vi.clearAllMocks()
|
||||
const wire = { tripNo: 'TR1', recordVersion: '90071992547409931', details: { actualDistanceMiles: '0', actualFuelLitres: null, remark: null, evidenceFiles: [] }, updatedAt: null }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: wire })
|
||||
const record = await getTripOperationsRecord('TR1')
|
||||
expect(record.details).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null, evidenceFiles: [] })
|
||||
const request = { recordVersion: record.recordVersion, idempotencyKey: 'stable-key', details: { actualDistanceMiles: record.details.actualDistanceMiles, actualFuelLitres: record.details.actualFuelLitres, remark: record.details.remark, evidenceFileIds: [] }, reason: null }
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: wire }); await saveTripOperationsRecord('TR1', request)
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record', request)
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...wire, occurredAt: '2026-10-03T00:00:00Z', reason: '更正' }], total: '1', page: '1', size: '20', pages: '1' } })
|
||||
const history = await listTripOperationsRecordHistory('TR1')
|
||||
expect(history.records[0]?.recordVersion).toBe('90071992547409931')
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record/history', { params: { page: 1, size: 20 } })
|
||||
})
|
||||
it('uploads multipart to the trip-scoped endpoint and preserves large file identities', async () => {
|
||||
vi.clearAllMocks()
|
||||
const file = new File(['pdf'], 'meter.pdf', { type: 'application/pdf' })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740993', fileName: 'meter.pdf', contentType: 'application/pdf', sizeBytes: '3' } })
|
||||
expect(await uploadTripOperationsEvidence('TR/1', file)).toMatchObject({ fileId: '9007199254740993', sizeBytes: 3 })
|
||||
expect(vi.mocked(http.post).mock.calls[0]![0]).toBe('/platform/v1/last-leg/fleet/operations/TR%2F1/record/files')
|
||||
expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('file')).toBe(file)
|
||||
await expect(uploadTripOperationsEvidence('TR1', new File(['html'], 'x.html', { type: 'text/html' }))).rejects.toThrow('PDF')
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('rejects JSON download errors and preserves correlation evidence instead of previewing them', async () => {
|
||||
const blob = new Blob(['pdf'], { type: 'application/pdf' }); vi.mocked(http.get).mockResolvedValue(blob)
|
||||
expect(await downloadTripOperationsEvidence('TR1', '81')).toBe(blob)
|
||||
vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无权读取', requestId: 'request-evidence', traceId: 'trace-evidence' })
|
||||
await expect(downloadTripOperationsEvidence('TR1', '81')).rejects.toMatchObject({ code: 'A0403', requestId: 'request-evidence', traceId: 'trace-evidence' })
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import axios from 'axios'
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes'
|
||||
import type { CostSummary, CostTotal } from './tripCosts'
|
||||
export interface TripOperationsQuery { page?: number; size?: number; from: string; to: string; keyword?: string; warehouseId?: string; sourceType?: 'OWN' | 'EXTERNAL'; status?: string }
|
||||
export interface TripOperationsMoney { currency: string; amount: number }
|
||||
type OperationsCostTotal = Pick<CostTotal, 'currency' | 'directCost' | 'ownCost' | 'externalCost'>
|
||||
export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] }
|
||||
export type OperationsRowCosts = Pick<CostSummary, 'allocationBlockReason'> & { totals: OperationsCostTotal[] }
|
||||
export interface TripOperationsEvidence { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface TripOperationsMeasured { actualDistanceMiles: number | null; actualFuelLitres: number | null; remark: string | null; evidenceFiles: TripOperationsEvidence[] }
|
||||
export type TripOperationsMeasuredInput = Omit<TripOperationsMeasured, 'evidenceFiles'> & { evidenceFileIds: string[] }
|
||||
export interface TripOperationsSeries { label: string; unit: string; points: { label: string; value: number }[] }
|
||||
export interface TripOperationsCharts { physicalPallets: TripOperationsSeries[]; destinationTrips: TripOperationsSeries[]; externalAgreedAmounts: TripOperationsSeries[]; externalPalletRates: TripOperationsSeries[] }
|
||||
export interface TripOperationsRow {
|
||||
tripNo: string; status: string; vehiclePlate: string | null; driverName: string | null; warehouseName: string | null; departedAt: string; completedAt: string | null; destinations: { key: string; label: string }[]
|
||||
physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; financeStatus: string; costStatus: string
|
||||
externalPalletRates: { currency: string; pricedPalletSpaces: number; agreedAmount: number }[]; measured: TripOperationsMeasured | null; costs: OperationsRowCosts | null
|
||||
}
|
||||
export interface TripOperationsSummary { trips: number; inTransit: number; completed: number; physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; costs: OperationsCosts }
|
||||
export interface TripOperationsResult { page: PageResult<TripOperationsRow>; summary: TripOperationsSummary; charts: TripOperationsCharts }
|
||||
type WireNumbers<T> = { [K in keyof T]: T[K] extends number ? number | string : T[K] }
|
||||
type WireMoney = { currency: string; amount: string | number }
|
||||
type WireEvidence = Omit<TripOperationsEvidence, 'sizeBytes'> & { sizeBytes: number | string }
|
||||
type WireMeasured = { actualDistanceMiles: string | number | null; actualFuelLitres: string | number | null; remark: string | null; evidenceFiles?: WireEvidence[] }
|
||||
type WireCostTotal = { currency: CostTotal['currency']; directCost: string | number; ownCost: string | number | null; externalCost: string | number | null }
|
||||
type WireRow = Omit<WireNumbers<TripOperationsRow>, 'externalAgreedAmounts' | 'measured' | 'externalPalletRates' | 'costs'> & { externalAgreedAmounts: WireMoney[]; measured: WireMeasured | null; externalPalletRates: { currency: string; pricedPalletSpaces: number | string; agreedAmount: number | string }[]; costs: { allocationBlockReason: string | null; totals: WireCostTotal[] } | null }
|
||||
type WireSummary = Omit<WireNumbers<TripOperationsSummary>, 'externalAgreedAmounts' | 'costs'> & { externalAgreedAmounts: WireMoney[]; costs: { visible: boolean; unrecordedTrips: string | number | null; unallocatedTrips: string | number | null; totals: WireCostTotal[] } }
|
||||
const costTotals = (rows: WireCostTotal[]): OperationsCostTotal[] => rows.map(r => ({ ...r, directCost: fleetMoney(r.directCost), ownCost: r.ownCost == null ? null : fleetMoney(r.ownCost), externalCost: r.externalCost == null ? null : fleetMoney(r.externalCost) }))
|
||||
const money = (rows: WireMoney[]) => rows.map(r => ({ ...r, amount: fleetMoney(r.amount) }))
|
||||
const counts = (r: WireRow | WireSummary) => ({ physicalPallets: fleetNumber(r.physicalPallets), palletSpaces: fleetNumber(r.palletSpaces), ownPackages: fleetNumber(r.ownPackages), externalPackages: fleetNumber(r.externalPackages), externalAgreedAmounts: money(r.externalAgreedAmounts) })
|
||||
const evidence = (r: WireEvidence): TripOperationsEvidence => {
|
||||
if (typeof r.fileId !== 'string' || !/^[1-9]\d*$/.test(r.fileId) || !r.fileName || !['application/pdf', 'image/jpeg', 'image/png'].includes(r.contentType)) throw new Error('实测凭证信息无效')
|
||||
return { ...r, sizeBytes: fleetNumber(r.sizeBytes) }
|
||||
}
|
||||
const measured = (r: WireMeasured): TripOperationsMeasured => ({ ...r, evidenceFiles: (r.evidenceFiles || []).map(evidence), actualDistanceMiles: r.actualDistanceMiles == null ? null : fleetNumber(r.actualDistanceMiles), actualFuelLitres: r.actualFuelLitres == null ? null : fleetNumber(r.actualFuelLitres) })
|
||||
type WireSeries = Omit<TripOperationsSeries, 'points'> & { points: { label: string; value: string | number }[] }
|
||||
type WireCharts = { [K in keyof TripOperationsCharts]: WireSeries[] }
|
||||
const series = (values: WireSeries[]) => values.map(s => ({ ...s, points: s.points.map(p => ({ ...p, value: fleetNumber(p.value) })) }))
|
||||
export async function listTripOperations(query: TripOperationsQuery): Promise<TripOperationsResult> {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>>('/platform/v1/last-leg/fleet/operations', { params: query }))
|
||||
return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } }
|
||||
}
|
||||
export const exportTripOperations = (query: TripOperationsQuery) => fleetCsv('/platform/v1/last-leg/fleet/operations/export', query)
|
||||
export interface TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null }
|
||||
export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasuredInput; reason: string | null }
|
||||
export interface TripOperationsRecordHistory { recordVersion: string; reason: string; occurredAt: string; details: TripOperationsMeasured }
|
||||
type WireRecord = Omit<TripOperationsRecord, 'recordVersion' | 'details'> & { recordVersion: string | number; details: WireMeasured }
|
||||
type WireHistory = Omit<TripOperationsRecordHistory, 'recordVersion' | 'details'> & { recordVersion: string | number; details: WireMeasured }
|
||||
const recordPath = (tripNo: string) => `/platform/v1/last-leg/fleet/operations/${encodeURIComponent(tripNo)}/record`
|
||||
const record = (v: WireRecord): TripOperationsRecord => ({ ...v, recordVersion: fleetVersion(v.recordVersion), details: measured(v.details) })
|
||||
export async function getTripOperationsRecord(tripNo: string) { return record(unwrapApiResponse(await http.get<ApiEnvelope<WireRecord>, ApiEnvelope<WireRecord>>(recordPath(tripNo)))) }
|
||||
export async function saveTripOperationsRecord(tripNo: string, request: TripOperationsRecordSave) { return record(unwrapApiResponse(await http.put<ApiEnvelope<WireRecord>, ApiEnvelope<WireRecord>>(recordPath(tripNo), request))) }
|
||||
export async function listTripOperationsRecordHistory(tripNo: string, page = 1, size = 20) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageResult<WireHistory>>, ApiEnvelope<PageResult<WireHistory>>>(`${recordPath(tripNo)}/history`, { params: { page, size } }))
|
||||
return fleetPage({ ...result, records: result.records.map(r => ({ ...r, recordVersion: fleetVersion(r.recordVersion), details: measured(r.details) })) })
|
||||
}
|
||||
export async function uploadTripOperationsEvidence(tripNo: string, file: File): Promise<TripOperationsEvidence> {
|
||||
if (!file.size || file.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)) throw new Error('请选择非空PDF、JPG或PNG文件,最大10MiB')
|
||||
const body = new FormData(); body.append('file', file)
|
||||
return evidence(unwrapApiResponse(await http.post<ApiEnvelope<WireEvidence>, ApiEnvelope<WireEvidence>>(`${recordPath(tripNo)}/files`, body)))
|
||||
}
|
||||
export async function downloadTripOperationsEvidence(tripNo: string, fileId: string): Promise<Blob> {
|
||||
async function rejectEnvelope(value: unknown, status: number) {
|
||||
if (value instanceof Blob && value.type.includes('json')) { try { value = JSON.parse(await value.text()) } catch { throw new Error('凭证接口返回了无法识别的错误') } }
|
||||
if (value && typeof value === 'object' && 'code' in value) { const e = value as ApiEnvelope<unknown>; throw new ApiResponseError(e.message || '凭证读取失败', e.code, status, e.data, e.requestId, e.traceId) }
|
||||
}
|
||||
try {
|
||||
const value = await http.get<Blob, Blob>(`${recordPath(tripNo)}/files/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' })
|
||||
await rejectEnvelope(value, 200)
|
||||
if (!(value instanceof Blob) || !value.size || !['application/pdf', 'image/jpeg', 'image/png'].includes(value.type) || value.size > 10 * 1024 * 1024) throw new Error('实测凭证下载失败或返回了错误响应')
|
||||
return value
|
||||
} catch (cause) { if (axios.isAxiosError(cause)) await rejectEnvelope(cause.response?.data, cause.response?.status || 0); throw cause }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getTripOperationsFinance } from './tripOperationsFinance'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('reads only the requested trip, separates currencies and keeps unknown receipts null', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { tripNo: 'TR/A', dispatchNos: ['DS-A'], receiptsVisible: true, attributionStatus: 'ATTRIBUTED', attributionReason: 'frozen', finance: { status: 'AVAILABLE', reason: 'authority', totals: [{ currency: 'CNY', receivableAmount: '0', receivedAmount: null }, { currency: 'USD', receivableAmount: '20.12', receivedAmount: '3.5' }], sources: [], documents: [] } } })
|
||||
const result = await getTripOperationsFinance('TR/A')
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/operations/TR%2FA/finance')
|
||||
expect(result.finance.totals).toEqual([{ currency: 'CNY', receivableAmount: 0, receivedAmount: null }, { currency: 'USD', receivableAmount: 20.12, receivedAmount: 3.5 }])
|
||||
})
|
||||
it('rejects a response belonging to another trip', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { tripNo: 'OTHER' } })
|
||||
await expect(getTripOperationsFinance('TR-A')).rejects.toThrow('车次不匹配')
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { fleetMoney } from './fleetQuotes'
|
||||
export interface TripFinanceMoney { currency: string; receivableAmount: number; receivedAmount: number | null }
|
||||
export interface TripFinanceSource { customerId: string; businessNo: string; sourceNo: string | null; feeItemName: string; sourceVersion: number; currency: string; amount: number; status: string }
|
||||
export interface TripFinanceDocument { billNo: string; currency: string; receivedAmount: number | null; allocationStatus: string; reason: string }
|
||||
export interface TripFinanceResult {
|
||||
tripNo: string; dispatchNos: string[]; receiptsVisible: boolean; attributionStatus: string; attributionReason: string
|
||||
finance: { status: string; totals: TripFinanceMoney[]; sources: TripFinanceSource[]; documents: TripFinanceDocument[]; reason: string }
|
||||
}
|
||||
type Wire = Omit<TripFinanceResult, 'finance'> & { finance: Omit<TripFinanceResult['finance'], 'totals' | 'sources' | 'documents'> & {
|
||||
totals: { currency: string; receivableAmount: string | number; receivedAmount: string | number | null }[]
|
||||
sources: (Omit<TripFinanceSource, 'amount'> & { amount: string | number })[]
|
||||
documents: (Omit<TripFinanceDocument, 'receivedAmount'> & { receivedAmount: string | number | null })[]
|
||||
} }
|
||||
export async function getTripOperationsFinance(tripNo: string): Promise<TripFinanceResult> {
|
||||
const data = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`/platform/v1/last-leg/fleet/operations/${encodeURIComponent(tripNo)}/finance`))
|
||||
if (data.tripNo !== tripNo) throw new Error('财务响应车次不匹配')
|
||||
return { ...data, finance: { ...data.finance,
|
||||
totals: data.finance.totals.map(m => ({ ...m, receivableAmount: fleetMoney(m.receivableAmount), receivedAmount: m.receivedAmount == null ? null : fleetMoney(m.receivedAmount) })),
|
||||
sources: data.finance.sources.map(s => ({ ...s, amount: fleetMoney(s.amount) })),
|
||||
documents: data.finance.documents.map(d => ({ ...d, receivedAmount: d.receivedAmount == null ? null : fleetMoney(d.receivedAmount) })),
|
||||
} }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import {
|
||||
listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
|
||||
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
|
||||
} from './trips'
|
||||
|
||||
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
const base = '/platform/v1/last-leg/fleet/trips'
|
||||
const longId = '9223372036854775801'
|
||||
const trip = { tripNo: 'TR/TEST', status: 'PLANNED', warehouseId: longId, warehouseCode: 'WH', warehouseName: '起运仓',
|
||||
vehicleId: longId, vehiclePlate: 'TEST-1', driverId: longId, driverName: '司机', driverPhone: '123',
|
||||
maxPalletSpaces: '30', maxLoadKg: '18000.125', maxVolumeCbm: '90.001', recordVersion: '0',
|
||||
plannedDepartureAt: '2026-10-02T10:00:00Z', expectedDriverEndAt: '2026-10-03T10:00:00Z', departedAt: null, completedAt: null,
|
||||
stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, updatedAt: '2026-10-02T08:00:00Z', vehicleRelease: null, driverRelease: null }
|
||||
const reviewState = { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
|
||||
const detail = { trip, driverQualification: null, loadingReview: reviewState, dispatches: [{ dispatchNo: 'DP-1', customerId: longId, packageCount: '12', boundBy: longId, boundAt: trip.updatedAt, review: reviewState }] }
|
||||
const cargo = { dispatchNo: 'DP-1', customerId: longId, dispatchItemId: longId, warehousePalletId: longId, palletNo: 'PALLET-1', packages: '12', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
|
||||
const load = { palletSpaces: '1', weightKg: '100.125', volumeCbm: '1.005' }
|
||||
const confirmed = { manifestHash: 'a'.repeat(64), cargo: [cargo], pallets: [{ ...load, warehousePalletId: longId, deliveryIndex: '1' }],
|
||||
segments: [{ fromIndex: '0', toIndex: '1', occupied: load, remaining: { palletSpaces: '29', weightKg: '17899.875', volumeCbm: '88.995' } }], operatorId: longId, confirmedAt: trip.updatedAt }
|
||||
const loading = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, confirmedLoad: confirmed, blockReason: null }
|
||||
const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: longId, destination: 'ONT8', status: 'DELIVERED', packages: '12', signedPackages: '8', returnedPackages: '4', remainingPackages: '0', terminalAt: trip.updatedAt }
|
||||
const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
|
||||
|
||||
describe('fleet trip API contract', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses one paged request and keeps long identifiers as exact strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' }))
|
||||
const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId }
|
||||
const result = await listTrips(query)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
|
||||
expect(result.total).toBe(121)
|
||||
expect(result.records[0]).toMatchObject({ vehicleId: longId, driverId: longId, warehouseId: longId, recordVersion: 0, maxPalletSpaces: 30, maxLoadKg: 18000.125 })
|
||||
})
|
||||
|
||||
it('encodes the business number and reads details without prefetching history or loading', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(detail))
|
||||
const result = await getTrip(trip.tripNo)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST`)
|
||||
expect(result.dispatches[0]).toMatchObject({ packageCount: 12, customerId: longId })
|
||||
})
|
||||
|
||||
it('forwards explicit versions and stable idempotency keys for every write without extra reads', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(envelope(detail)); vi.mocked(http.put).mockResolvedValue(envelope(detail))
|
||||
const draft = { warehouseId: longId, plannedDepartureAt: trip.plannedDepartureAt, expectedDriverEndAt: trip.expectedDriverEndAt, stops: trip.stops, idempotencyKey: 'same-create-key' }
|
||||
await createTrip(draft); await createTrip(draft)
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, base, draft); expect(http.post).toHaveBeenNthCalledWith(2, base, draft)
|
||||
const plan = { recordVersion: 0, plannedDepartureAt: draft.plannedDepartureAt, expectedDriverEndAt: draft.expectedDriverEndAt, stops: draft.stops, reason: '调整' }
|
||||
await updateTripPlan(trip.tripNo, plan)
|
||||
expect(http.put).toHaveBeenCalledWith(`${base}/TR%2FTEST/plan`, plan)
|
||||
const assignment = { recordVersion: 0, vehicleId: longId, driverId: longId, reason: '分配' }
|
||||
await assignTrip(trip.tripNo, assignment)
|
||||
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/assignment`, assignment)
|
||||
const binding = { recordVersion: 1, dispatchNo: 'DP-1', reason: '关联' }
|
||||
await bindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/bind`, binding)
|
||||
await unbindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/unbind`, binding)
|
||||
await cancelTrip(trip.tripNo, { recordVersion: 2, reason: '取消' })
|
||||
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/cancel`, { recordVersion: 2, reason: '取消' })
|
||||
const complete = { recordVersion: 4, idempotencyKey: 'complete-key', reason: '核对' }
|
||||
await completeTrip(trip.tripNo, complete); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/complete`, complete)
|
||||
const release = { recordVersion: 5, resourceKind: 'DRIVER' as const, actualEndAt: trip.updatedAt, endLocation: '交接点', idempotencyKey: 'release-key', reason: '返程已结束' }
|
||||
await releaseTripResource(trip.tripNo, release); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/resources/release`, release)
|
||||
const review = { recordVersion: 6, decision: 'APPROVED' as const }
|
||||
await reviewTripDispatch(trip.tripNo, 'DP/1', review)
|
||||
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/DP%2F1/review`, review)
|
||||
await reviewTripLoading(trip.tripNo, review)
|
||||
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/loading-review`, review)
|
||||
expect(http.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps planned capacity separate from actual load and consumes source freshness', async () => {
|
||||
const plan = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, capacityPlan: confirmed, planValid: false, blockReason: null, availableSegments: confirmed.segments }
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(plan)); vi.mocked(http.post).mockResolvedValueOnce(envelope({ ...plan, planValid: true, recordVersion: '4' }))
|
||||
const result = await getTripCapacityPlan(trip.tripNo)
|
||||
expect(result.planValid).toBe(false); expect(result.capacityPlan?.segments[0]?.remaining.palletSpaces).toBe(29)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`)
|
||||
const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }], reason: '计划核对' }
|
||||
expect((await confirmTripCapacityPlan(trip.tripNo, body)).recordVersion).toBe(4)
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`, body)
|
||||
})
|
||||
|
||||
it('preserves source fingerprints and uses server capacity results without recomputing them', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(loading)); vi.mocked(http.post).mockResolvedValueOnce(envelope(loading))
|
||||
const result = await getTripLoading(trip.tripNo)
|
||||
expect(result.confirmedLoad?.segments[0]?.remaining).toEqual({ palletSpaces: 29, weightKg: 17899.875, volumeCbm: 88.995 })
|
||||
expect(result.cargo[0]?.warehousePalletId).toBe(longId)
|
||||
const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100.125, volumeCbm: 1.005, deliveryIndex: 1 }], reason: '核对实物' }
|
||||
await confirmTripLoading(trip.tripNo, body)
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/loading`, body)
|
||||
})
|
||||
|
||||
it('loads completion and immutable history only when explicitly requested', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ tripNo: trip.tripNo, recordVersion: '5', canComplete: false, blockReason: '另一票未签收', outcomes: [outcome] }))
|
||||
expect(await getTripCompletion(trip.tripNo)).toMatchObject({ recordVersion: 5, canComplete: false, blockReason: '另一票未签收', outcomes: [{ packages: 12, signedPackages: 8, returnedPackages: 4, remainingPackages: 0 }] })
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ page: '2', size: '10', total: '12', pages: '2', records: [{ recordVersion: '5', action: 'COMPLETE', reason: null, operatorId: longId, occurredAt: trip.updatedAt,
|
||||
snapshot: { trip, externalBookings: [], capacityPlan: null, loadingReview: reviewState, dispatches: detail.dispatches, confirmedLoad: confirmed, completion: { idempotencyKey: 'key', requestHash: 'hash', completedAt: trip.updatedAt, recordedAt: trip.updatedAt, operatorId: longId, outcomes: [outcome] } } }] }))
|
||||
const history = await listTripHistory(trip.tripNo, 2, 10)
|
||||
expect(http.get).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/history`, { params: { page: 2, size: 10 } })
|
||||
expect(history.records[0]?.snapshot.completion?.outcomes[0]?.packages).toBe(12)
|
||||
expect(history.records[0]?.snapshot.completion?.outcomes[0]).toMatchObject({ signedPackages: 8, returnedPackages: 4, remainingPackages: 0 })
|
||||
expect(history.records[0]?.snapshot.trip.recordVersion).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects missing or unsafe versions and preserves backend conflict diagnostics', async () => {
|
||||
for (const version of [null, '', '9007199254740993', '-1']) {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...detail, trip: { ...trip, recordVersion: version } }))
|
||||
await expect(getTrip(trip.tripNo)).rejects.toThrow()
|
||||
}
|
||||
const conflict = new ApiResponseError('车次已修改', '409', 200, null, 'request-1', 'trace-1')
|
||||
vi.mocked(http.post).mockRejectedValueOnce(conflict)
|
||||
await expect(cancelTrip(trip.tripNo, { recordVersion: 0, reason: '取消' })).rejects.toBe(conflict)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeBooking, type WireBooking } from './tripBookings'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
import type {
|
||||
FleetTrip, TripStatus, TripDetail, TripDraft, TripPlan, TripAssignment, TripBindingCommand, TripBinding,
|
||||
TripCargo, TripLoad, TripMeasurement, TripConfirmedLoad, TripLoading, TripLoadConfirmation,
|
||||
TripOutcome, TripCompletionInspection, TripComplete, TripResourceRelease, TripHistory, TripReviewState, TripReviewCommand, TripCapacityPlanning,
|
||||
} from '../tripTypes'
|
||||
|
||||
const base = '/platform/v1/last-leg/fleet/trips'
|
||||
const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}`
|
||||
type Numeric = number | string
|
||||
type WireTrip = Omit<FleetTrip, 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg' | 'maxVolumeCbm'> & {
|
||||
recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null
|
||||
}
|
||||
type WireReview = Omit<TripReviewState, 'recordVersion'> & { recordVersion: Numeric | null }
|
||||
type WireBinding = Omit<TripBinding, 'packageCount' | 'review'> & { packageCount: Numeric; review: WireReview }
|
||||
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview }
|
||||
type WirePage<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
type WireCargo = Omit<TripCargo, 'packages'> & { packages: Numeric }
|
||||
type WireLoad = { [K in keyof TripLoad]: Numeric }
|
||||
type WireMeasurement = Omit<TripMeasurement, 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'deliveryIndex'> & WireLoad & { deliveryIndex: Numeric }
|
||||
type WireConfirmed = Omit<TripConfirmedLoad, 'cargo' | 'pallets' | 'segments'> & {
|
||||
cargo: WireCargo[]; pallets: WireMeasurement[]
|
||||
segments: { fromIndex: Numeric; toIndex: Numeric; occupied: WireLoad; remaining: WireLoad }[]
|
||||
}
|
||||
type WireLoading = Omit<TripLoading, 'recordVersion' | 'cargo' | 'confirmedLoad'> & { recordVersion: Numeric; cargo: WireCargo[]; confirmedLoad: WireConfirmed | null }
|
||||
type WirePlanning = Omit<TripCapacityPlanning, 'recordVersion' | 'cargo' | 'capacityPlan' | 'availableSegments'> & { recordVersion: Numeric; cargo: WireCargo[]; capacityPlan: WireConfirmed | null; availableSegments: WireConfirmed['segments'] }
|
||||
type WireOutcome = Omit<TripOutcome, 'packages' | 'signedPackages' | 'returnedPackages' | 'remainingPackages'> & { packages: Numeric; signedPackages: Numeric; returnedPackages: Numeric; remainingPackages: Numeric }
|
||||
type WireInspection = Omit<TripCompletionInspection, 'recordVersion' | 'outcomes'> & { recordVersion: Numeric; outcomes: WireOutcome[] }
|
||||
type WireHistory = Omit<TripHistory, 'recordVersion' | 'snapshot'> & {
|
||||
recordVersion: Numeric
|
||||
snapshot: { externalBookings: WireBooking[]; capacityPlan: WireConfirmed | null; trip: WireTrip; dispatches: WireBinding[]; confirmedLoad: WireConfirmed | null; loadingReview: WireReview; completion: (Omit<NonNullable<TripHistory['snapshot']['completion']>, 'outcomes'> & { outcomes: WireOutcome[] }) | null }
|
||||
}
|
||||
|
||||
function number(value: Numeric): number {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') throw new Error('车次接口缺少有效数值字段')
|
||||
if (typeof value === 'string' && !value.trim()) throw new Error('车次接口返回了空的数值字段')
|
||||
const result = Number(value)
|
||||
if (!Number.isFinite(result) || result < 0) throw new Error('车次接口返回了无效数值')
|
||||
return result
|
||||
}
|
||||
function integer(value: Numeric): number {
|
||||
const result = number(value)
|
||||
if (!Number.isSafeInteger(result)) throw new Error('车次接口返回了无效分页、数量或版本')
|
||||
return result
|
||||
}
|
||||
const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer(value.recordVersion),
|
||||
maxPalletSpaces: value.maxPalletSpaces == null ? null : integer(value.maxPalletSpaces),
|
||||
maxLoadKg: value.maxLoadKg == null ? null : number(value.maxLoadKg), maxVolumeCbm: value.maxVolumeCbm == null ? null : number(value.maxVolumeCbm) })
|
||||
const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) })
|
||||
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), review: review(value.review) })
|
||||
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), loadingReview: review(value.loadingReview) })
|
||||
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
|
||||
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
|
||||
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })
|
||||
const load = (value: WireLoad): TripLoad => ({ palletSpaces: integer(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) })
|
||||
function confirmed(value: WireConfirmed | null): TripConfirmedLoad | null {
|
||||
return value == null ? null : { ...value, cargo: value.cargo.map(cargo),
|
||||
pallets: value.pallets.map(row => ({ ...row, ...load(row), deliveryIndex: integer(row.deliveryIndex) })),
|
||||
segments: value.segments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) }
|
||||
}
|
||||
const loading = (value: WireLoading): TripLoading => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), confirmedLoad: confirmed(value.confirmedLoad) })
|
||||
function page<T, W>(value: WirePage<W>, map: (row: W) => T): PageResult<T> {
|
||||
return { records: value.records.map(map), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) }
|
||||
}
|
||||
export interface TripQuery { page: number; size: number; keyword?: string; status?: TripStatus; warehouseId?: string }
|
||||
export async function listTrips(query: TripQuery): Promise<PageResult<FleetTrip>> {
|
||||
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
|
||||
}
|
||||
export async function getTrip(tripNo: string): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
|
||||
}
|
||||
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
|
||||
}
|
||||
export const createTrip = (body: TripDraft) => postDetail(base, body)
|
||||
export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body)
|
||||
export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body)
|
||||
export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body)
|
||||
export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body)
|
||||
export const unbindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/unbind`, body)
|
||||
export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason: string }) => postDetail(`${path(tripNo)}/cancel`, body)
|
||||
export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body)
|
||||
export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body)
|
||||
export async function updateTripPlan(tripNo: string, body: TripPlan): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
|
||||
}
|
||||
const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) })
|
||||
export async function getTripCapacityPlan(tripNo: string): Promise<TripCapacityPlanning> {
|
||||
return planning(unwrapApiResponse(await http.get<ApiEnvelope<WirePlanning>, ApiEnvelope<WirePlanning>>(`${path(tripNo)}/capacity-plan`)))
|
||||
}
|
||||
export async function confirmTripCapacityPlan(tripNo: string, body: TripLoadConfirmation): Promise<TripCapacityPlanning> {
|
||||
return planning(unwrapApiResponse(await http.post<ApiEnvelope<WirePlanning>, ApiEnvelope<WirePlanning>>(`${path(tripNo)}/capacity-plan`, body)))
|
||||
}
|
||||
export async function getTripLoading(tripNo: string): Promise<TripLoading> {
|
||||
return loading(unwrapApiResponse(await http.get<ApiEnvelope<WireLoading>, ApiEnvelope<WireLoading>>(`${path(tripNo)}/loading`)))
|
||||
}
|
||||
export async function confirmTripLoading(tripNo: string, body: TripLoadConfirmation): Promise<TripLoading> {
|
||||
return loading(unwrapApiResponse(await http.post<ApiEnvelope<WireLoading>, ApiEnvelope<WireLoading>>(`${path(tripNo)}/loading`, body)))
|
||||
}
|
||||
export async function getTripCompletion(tripNo: string): Promise<TripCompletionInspection> {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<WireInspection>, ApiEnvelope<WireInspection>>(`${path(tripNo)}/completion`))
|
||||
return { ...value, canComplete: value.canComplete === true, recordVersion: integer(value.recordVersion), outcomes: value.outcomes.map(outcome) }
|
||||
}
|
||||
export async function listTripHistory(tripNo: string, currentPage = 1, size = 20): Promise<PageResult<TripHistory>> {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireHistory>>, ApiEnvelope<WirePage<WireHistory>>>(`${path(tripNo)}/history`, { params: { page: currentPage, size } }))
|
||||
return page(value, row => ({ ...row, recordVersion: integer(row.recordVersion), snapshot: {
|
||||
trip: trip(row.snapshot.trip), dispatches: row.snapshot.dispatches.map(binding), confirmedLoad: confirmed(row.snapshot.confirmedLoad),
|
||||
externalBookings: row.snapshot.externalBookings.map(normalizeBooking), loadingReview: review(row.snapshot.loadingReview), capacityPlan: confirmed(row.snapshot.capacityPlan),
|
||||
completion: row.snapshot.completion == null ? null : { ...row.snapshot.completion, outcomes: row.snapshot.completion.outcomes.map(outcome) },
|
||||
} }))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http, ApiResponseError } from '@/shared/api/http'
|
||||
import { listVehicleCare, createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto, downloadVehicleCarePhoto, listVehicleCareFinance, submitVehicleCareFinance } from './vehicleCare'
|
||||
import type { VehicleCareDraft } from '../vehicleCareTypes'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
const id = '9223372036854775701', base = `/platform/v1/last-leg/fleet/vehicles/${id}/records`
|
||||
const details: VehicleCareDraft = { recordType: 'FUEL', occurredAt: '2026-01-01T00:00:00Z', distanceUnit: 'MI', odometer: 100, lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL', verificationStatus: 'PENDING', remark: null, incident: null }
|
||||
const wire = { recordNo: 'VR-ONE', vehicleId: id, vehiclePlate: 'TEST', recordVersion: '2', details: { ...details, odometer: '100', fuelQuantity: '28.6' }, files: [{ fileId: '9223372036854775702', fileName: 'meter.png', contentType: 'image/png', sizeBytes: '8' }], updatedBy: 'operator', updatedAt: '2026-01-01T00:00:00Z' }
|
||||
const ok = (data: unknown) => ({ code: '200', message: '成功', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('queries financial status once for the visible records and preserves unknown amounts', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok([{ recordNo: 'VR-ONE', status: 'PENDING_FINANCE', sourceVersion: '90071992547409931', submittedRecordVersion: '2' }]))
|
||||
const result = await listVehicleCareFinance(id, ['VR-ONE'])
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/finance-status`, { recordNos: ['VR-ONE'] })
|
||||
expect(result[0]).toMatchObject({ status: 'PENDING_FINANCE', sourceVersion: '90071992547409931', finance: null })
|
||||
await listVehicleCareFinance(id, []); expect(http.post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('rejects incomplete or cross-record financial status instead of pretending it is unlinked', async () => {
|
||||
vi.mocked(http.post).mockResolvedValueOnce(ok([])).mockResolvedValueOnce(ok([{ recordNo: 'VR-OTHER', status: 'PAID' }]))
|
||||
await expect(listVehicleCareFinance(id, ['VR-ONE'])).rejects.toThrow('全部')
|
||||
await expect(listVehicleCareFinance(id, ['VR-ONE'])).rejects.toThrow('归属')
|
||||
})
|
||||
it('submits the saved version and stable key without sending amounts or a customer', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok({ recordNo: 'VR-ONE', sourceVersion: '1', submittedRecordVersion: '2', replayed: false, status: 'PENDING_FINANCE' }))
|
||||
const request = { recordVersion: 2, supplierId: '9223372036854775703', businessDate: '2026-01-01', idempotencyKey: 'same-finance-key' }
|
||||
const result = await submitVehicleCareFinance(id, 'VR-ONE', request)
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/VR-ONE/finance`, request)
|
||||
expect(result.status).toBe('PENDING_FINANCE')
|
||||
})
|
||||
it('normalizes omitted nullable incident fields without injecting maintenance defaults', async () => {
|
||||
const repair = { ...wire, details: { recordType: 'REPAIR', occurredAt: details.occurredAt,
|
||||
incident: { description: '轮胎维修', item: '换胎', place: '现场', processingStatus: 'PENDING', reportedCost: '125.50', currency: 'USD' } } }
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ records: [repair], page: 1, size: 10, total: 1, pages: 1 }))
|
||||
const result = (await listVehicleCare(id, 'REPAIR')).records[0]!
|
||||
expect(result.details).toMatchObject({ distanceUnit: null, odometer: null, fuelUnit: null, verificationStatus: null,
|
||||
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, remark: null,
|
||||
incident: { handler: null, exceptionType: null, reportedCost: 125.5, currency: 'USD' } })
|
||||
})
|
||||
it('keeps vehicle and file IDs exact while normalizing values without changing units', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ records: [wire], page: '1', size: '10', total: '1', pages: '1' }))
|
||||
const result = await listVehicleCare(id, 'FUEL')
|
||||
expect(result.records[0]).toMatchObject({ vehicleId: id, recordVersion: 2, details, files: [{ fileId: '9223372036854775702', sizeBytes: 8 }] })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: { recordType: 'FUEL', page: 1, size: 10 } })
|
||||
})
|
||||
it('preserves creation and upload keys and sends explicit correction version and reason', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok(wire)); vi.mocked(http.put).mockResolvedValue(ok(wire))
|
||||
await createVehicleCare(id, 'same-create-key', details)
|
||||
await updateVehicleCare(id, 'VR-ONE', 2, '核对里程', details)
|
||||
const photo = new File(['photo'], 'meter.png', { type: 'image/png' })
|
||||
await uploadVehicleCarePhoto(id, 'VR-ONE', 3, 'same-photo-key', photo)
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, base, { idempotencyKey: 'same-create-key', details })
|
||||
expect(http.put).toHaveBeenCalledWith(`${base}/VR-ONE`, { recordVersion: 2, reason: '核对里程', details })
|
||||
const [url, form] = vi.mocked(http.post).mock.calls[1]!
|
||||
expect(url).toBe(`${base}/VR-ONE/files`); expect(form).toBeInstanceOf(FormData)
|
||||
expect((form as FormData).get('recordVersion')).toBe('3'); expect((form as FormData).get('idempotencyKey')).toBe('same-photo-key')
|
||||
expect((form as FormData).get('file')).toBe(photo)
|
||||
})
|
||||
it('rejects JSON business errors and non-image bodies instead of opening them as photos', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['image'], { type: 'image/png' }))
|
||||
.mockResolvedValueOnce({ code: 'A0404', message: '图片不属于记录', requestId: 'test-request' })
|
||||
.mockResolvedValueOnce(new Blob(['html'], { type: 'text/html' }))
|
||||
expect((await downloadVehicleCarePhoto(id, 'VR-ONE', '71')).type).toBe('image/png')
|
||||
await expect(downloadVehicleCarePhoto(id, 'VR-ONE', '72')).rejects.toMatchObject({ name: 'ApiResponseError', code: 'A0404', requestId: 'test-request' } satisfies Partial<ApiResponseError>)
|
||||
await expect(downloadVehicleCarePhoto(id, 'VR-ONE', '73')).rejects.toThrow('格式无效')
|
||||
})
|
||||
it('keeps unknown and zero incident costs distinct and preserves independent payment state', async () => {
|
||||
const incident = { description: '救援完成', item: '拖车', place: '现场', handler: '处理人', processingStatus: 'COMPLETED', exceptionType: null, reportedCost: '0.00', currency: 'USD' }
|
||||
const rescue = { ...wire, paymentStatus: 'UNLINKED', details: { ...details, recordType: 'RESCUE', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null, incident } }
|
||||
vi.mocked(http.get).mockResolvedValue(ok({ records: [rescue, { ...rescue, recordNo: 'VR-TWO', details: { ...rescue.details, incident: { ...incident, reportedCost: null, currency: null } } }], page: 1, size: 10, total: 2, pages: 1 }))
|
||||
const result = await listVehicleCare(id, 'RESCUE')
|
||||
expect(result.records[0]).toMatchObject({ paymentStatus: 'UNLINKED', details: { odometer: null, incident: { reportedCost: 0, currency: 'USD' } } })
|
||||
expect(result.records[1]?.details.incident?.reportedCost).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import axios from 'axios'
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { VehicleCareDraft, VehicleCareRecord, VehicleCareType, VehicleIncident } from '../vehicleCareTypes'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
type Numeric = number | string
|
||||
type Wire = Omit<VehicleCareRecord, 'recordVersion' | 'details' | 'files'> & {
|
||||
recordVersion: Numeric
|
||||
details: Pick<VehicleCareDraft, 'recordType' | 'occurredAt'> & Partial<Omit<VehicleCareDraft, 'odometer' | 'lastServiceOdometer' | 'nextServiceOdometer' | 'fuelQuantity' | 'incident'>> & {
|
||||
odometer?: Numeric | null; lastServiceOdometer?: Numeric | null; nextServiceOdometer?: Numeric | null; fuelQuantity?: Numeric | null
|
||||
incident?: (Pick<VehicleIncident, 'description' | 'processingStatus'> & Partial<Omit<VehicleIncident, 'reportedCost'>> & { reportedCost?: Numeric | null }) | null
|
||||
}
|
||||
files: Array<Omit<VehicleCareRecord['files'][number], 'sizeBytes'> & { sizeBytes: Numeric }>
|
||||
}
|
||||
const path = (vehicleId: string, recordNo?: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}/records${recordNo ? `/${encodeURIComponent(recordNo)}` : ''}`
|
||||
export const vehicleCareFinanceSubmitPermission = 'platform:overseas-master-data:vehicle:record-finance-submit'
|
||||
export type VehicleCareFinanceState = 'UNLINKED' | 'PENDING_FINANCE' | 'FINANCE_ATTENTION' | 'PENDING' | 'APPROVED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
||||
export interface VehicleCareFinanceStatus {
|
||||
recordNo: string; status: VehicleCareFinanceState; sourceVersion: string | null; submittedRecordVersion: string | null
|
||||
finance: { costNo: string; status: string; currency: 'CNY'; supplierName: string; paidAmount: number; outstandingAmount: number | null;
|
||||
source: { originalCurrency: 'CNY' | 'USD'; originalAmount: number; amount: number; businessDate: string; exchangeRate: number; exchangeRateMonth: string } } | null
|
||||
}
|
||||
export interface VehicleCareFinanceSubmit { recordVersion: number; supplierId: string; businessDate: string; idempotencyKey: string }
|
||||
export interface VehicleCareFinanceSubmitted { recordNo: string; sourceVersion: string; submittedRecordVersion: string; replayed: boolean; status: 'PENDING_FINANCE' }
|
||||
export const vehicleCareFinanceLabels: Record<VehicleCareFinanceState, string> = {
|
||||
UNLINKED: '未提交财务', PENDING_FINANCE: '待财务接收', FINANCE_ATTENTION: '财务接收需处理', PENDING: '待财务入账', APPROVED: '已入账 · 未付款',
|
||||
PARTIALLY_PAID: '部分付款', PAID: '已付款', VOIDED: '已作废',
|
||||
}
|
||||
function financeVersion(value: Numeric): string {
|
||||
if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('财务版本无效')
|
||||
return String(value)
|
||||
}
|
||||
function financeAmount(value: Numeric): number {
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('财务金额无效')
|
||||
const [whole, fraction = ''] = String(value).split('.')
|
||||
const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0'))
|
||||
if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('财务金额超过精确展示范围')
|
||||
return Number(value)
|
||||
}
|
||||
export async function listVehicleCareFinance(vehicleId: string, recordNos: string[]): Promise<VehicleCareFinanceStatus[]> {
|
||||
if (!recordNos.length) return []
|
||||
type Cost = NonNullable<VehicleCareFinanceStatus['finance']>
|
||||
type StatusWire = Omit<VehicleCareFinanceStatus, 'sourceVersion' | 'submittedRecordVersion' | 'finance'> & {
|
||||
sourceVersion?: Numeric | null; submittedRecordVersion?: Numeric | null
|
||||
finance?: (Omit<Cost, 'paidAmount' | 'outstandingAmount' | 'source'> & { paidAmount: Numeric; outstandingAmount?: Numeric | null;
|
||||
source: Omit<Cost['source'], 'originalAmount' | 'amount' | 'exchangeRate'> & { originalAmount: Numeric; amount: Numeric; exchangeRate: Numeric } }) | null
|
||||
}
|
||||
const response = unwrapApiResponse(await http.post<ApiEnvelope<StatusWire[]>, ApiEnvelope<StatusWire[]>>(`${path(vehicleId)}/finance-status`, { recordNos }))
|
||||
const seen = new Set<string>()
|
||||
const result = response.map(value => {
|
||||
if (!recordNos.includes(value.recordNo) || seen.has(value.recordNo) || !Object.hasOwn(vehicleCareFinanceLabels, value.status)) throw new Error('财务状态归属或内容无效')
|
||||
seen.add(value.recordNo)
|
||||
const cost = value.finance
|
||||
if (!['UNLINKED', 'PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(value.status) && (!cost || cost.status !== value.status)) throw new Error('财务入账或付款状态缺少真实依据')
|
||||
if (cost && cost.currency !== 'CNY') throw new Error('财务结算币种无效')
|
||||
return { ...value, sourceVersion: value.sourceVersion == null ? null : financeVersion(value.sourceVersion),
|
||||
submittedRecordVersion: value.submittedRecordVersion == null ? null : financeVersion(value.submittedRecordVersion),
|
||||
finance: cost ? { ...cost, paidAmount: financeAmount(cost.paidAmount), outstandingAmount: cost.outstandingAmount == null ? null : financeAmount(cost.outstandingAmount),
|
||||
source: { ...cost.source, originalAmount: financeAmount(cost.source.originalAmount), amount: financeAmount(cost.source.amount), exchangeRate: decimal(cost.source.exchangeRate) } } : null }
|
||||
})
|
||||
if (seen.size !== new Set(recordNos).size) throw new Error('财务状态未返回全部所选记录')
|
||||
return result
|
||||
}
|
||||
export async function submitVehicleCareFinance(vehicleId: string, recordNo: string, request: VehicleCareFinanceSubmit): Promise<VehicleCareFinanceSubmitted> {
|
||||
type Wire = Omit<VehicleCareFinanceSubmitted, 'sourceVersion' | 'submittedRecordVersion'> & { sourceVersion: Numeric; submittedRecordVersion: Numeric }
|
||||
const response = unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${path(vehicleId, recordNo)}/finance`, request))
|
||||
if (response.recordNo !== recordNo || response.status !== 'PENDING_FINANCE') throw new Error('财务提交响应不匹配,请使用原请求重试')
|
||||
return { ...response, sourceVersion: financeVersion(response.sourceVersion), submittedRecordVersion: financeVersion(response.submittedRecordVersion) }
|
||||
}
|
||||
export async function listVehicleCareSuppliers(keyword = ''): Promise<Array<{ id: string; name: string }>> {
|
||||
type Supplier = { id: Numeric; name: string; status: string }
|
||||
const response = unwrapApiResponse(await http.get<ApiEnvelope<{ records: Supplier[] }>, ApiEnvelope<{ records: Supplier[] }>>('/platform/v1/suppliers',
|
||||
{ params: { page: 1, size: 50, status: 'ACTIVE', keyword } }))
|
||||
return response.records.filter(row => row.status === 'ACTIVE').map(row => ({ id: financeVersion(row.id), name: row.name }))
|
||||
}
|
||||
function integer(value: Numeric): number {
|
||||
const result = Number(value)
|
||||
if (!Number.isSafeInteger(result) || result < 0) throw new Error('运维接口返回了无效的分页或版本')
|
||||
return result
|
||||
}
|
||||
function decimal(value: Numeric): number {
|
||||
const result = Number(value)
|
||||
if (value === '' || !Number.isFinite(result) || result < 0) throw new Error('运维接口返回了无效数值')
|
||||
return result
|
||||
}
|
||||
function record(value: Wire): VehicleCareRecord {
|
||||
const optional = (number: Numeric | null | undefined) => number == null ? null : decimal(number)
|
||||
const details = value.details, incident = details.incident
|
||||
return { ...value, recordVersion: integer(value.recordVersion), paymentStatus: value.paymentStatus ?? null, details: {
|
||||
recordType: details.recordType, occurredAt: details.occurredAt, distanceUnit: details.distanceUnit ?? null,
|
||||
odometer: optional(details.odometer), lastServiceOdometer: optional(details.lastServiceOdometer),
|
||||
nextServiceOdometer: optional(details.nextServiceOdometer), fuelQuantity: optional(details.fuelQuantity),
|
||||
fuelUnit: details.fuelUnit ?? null, verificationStatus: details.verificationStatus ?? null, remark: details.remark ?? null,
|
||||
incident: incident ? { description: incident.description, processingStatus: incident.processingStatus,
|
||||
item: incident.item ?? null, place: incident.place ?? null, handler: incident.handler ?? null,
|
||||
exceptionType: incident.exceptionType ?? null, reportedCost: optional(incident.reportedCost), currency: incident.currency ?? null } : null },
|
||||
files: value.files.map(file => ({ ...file, sizeBytes: integer(file.sizeBytes) })) }
|
||||
}
|
||||
export async function listVehicleCare(vehicleId: string, recordType: VehicleCareType, page = 1, size = 10): Promise<PageResult<VehicleCareRecord>> {
|
||||
type PageWire = { records: Wire[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageWire>, ApiEnvelope<PageWire>>(path(vehicleId), { params: { recordType, page, size } }))
|
||||
return { records: result.records.map(record), page: integer(result.page), size: integer(result.size), total: integer(result.total), pages: integer(result.pages) }
|
||||
}
|
||||
export async function createVehicleCare(vehicleId: string, idempotencyKey: string, details: VehicleCareDraft): Promise<VehicleCareRecord> {
|
||||
return record(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId), { idempotencyKey, details })))
|
||||
}
|
||||
export async function getVehicleCare(vehicleId: string, recordNo: string): Promise<VehicleCareRecord> {
|
||||
return record(unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId, recordNo))))
|
||||
}
|
||||
export async function updateVehicleCare(vehicleId: string, recordNo: string, recordVersion: number, reason: string, details: VehicleCareDraft): Promise<VehicleCareRecord> {
|
||||
return record(unwrapApiResponse(await http.put<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId, recordNo), { recordVersion, reason, details })))
|
||||
}
|
||||
export async function uploadVehicleCarePhoto(vehicleId: string, recordNo: string, recordVersion: number, idempotencyKey: string, file: File): Promise<VehicleCareRecord> {
|
||||
const form = new FormData(); form.append('recordVersion', String(recordVersion)); form.append('idempotencyKey', idempotencyKey); form.append('file', file)
|
||||
return record(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${path(vehicleId, recordNo)}/files`, form)))
|
||||
}
|
||||
async function downloadError(value: unknown, status: number): Promise<void> {
|
||||
if (value instanceof Blob && value.type.includes('json')) {
|
||||
try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') }
|
||||
}
|
||||
if (value && typeof value === 'object' && 'code' in value) {
|
||||
const error = value as ApiEnvelope<unknown>
|
||||
throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId)
|
||||
}
|
||||
}
|
||||
export async function downloadVehicleCarePhoto(vehicleId: string, recordNo: string, fileId: string): Promise<Blob> {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(`${path(vehicleId, recordNo)}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' })
|
||||
await downloadError(blob, 200)
|
||||
if (!(blob instanceof Blob) || !blob.size || !['image/png', 'image/jpeg'].includes(blob.type)) 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,17 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listVehicleDeliveries, listVehicleAssignmentChanges } from './vehicleFulfillment'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
it('encodes the stable vehicle key and adapts serialized pagination without a plate or customer override', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ deliveryNo: 'DL-1' }], page: '1', size: '10', total: '1', pages: '1' } })
|
||||
const result=await listVehicleDeliveries('900719925474099301')
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/vehicles/900719925474099301/deliveries', { params: { page: 1, size: 10 } })
|
||||
expect(result.total).toBe(1)
|
||||
})
|
||||
it('history preserves large serialized versions and rejects invalid pagination', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ tripNo:'TR-1', recordVersion:'900719925474099301' }], page:'1',size:'10',total:'1',pages:'1' } })
|
||||
expect((await listVehicleAssignmentChanges('8')).records[0]?.recordVersion).toBe('900719925474099301')
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page:'1',size:'10',total:'not-a-number',pages:'1' } })
|
||||
await expect(listVehicleAssignmentChanges('8')).rejects.toThrow('分页数值')
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
export interface VehicleDelivery {
|
||||
deliveryNo: string; dispatchNo: string; tripNo: string; bolNo: string | null; customerName: string | null
|
||||
destinationType: string; destinationWarehouseCode: string | null; destinationWarehouseName: string | null
|
||||
deliveryAddress: string | null; plannedDeliveryAt: string | null; status: string; relationship: 'PLANNED' | 'DEPARTED'
|
||||
}
|
||||
export interface VehicleAssignmentChange {
|
||||
tripNo: string; recordVersion: string; occurredAt: string; reason: string | null
|
||||
previousVehiclePlate: string | null; vehiclePlate: string | null; previousDriverName: string | null; driverName: string | null
|
||||
relationship: 'REPLACED_OUT' | 'REPLACED_IN' | 'DRIVER_CHANGED'; dispatchNos: string[]
|
||||
}
|
||||
type Numeric = string | number
|
||||
type WirePage<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
type WireChange = Omit<VehicleAssignmentChange, 'recordVersion'> & { recordVersion: Numeric }
|
||||
function integer(value: Numeric) {
|
||||
const number = Number(value)
|
||||
if (value == null || value === '' || !Number.isSafeInteger(number) || number < 0) throw new Error('车辆履约接口返回无效分页数值')
|
||||
return number
|
||||
}
|
||||
function page<T, W>(value: WirePage<W>, normalize: (row: W) => T): PageResult<T> {
|
||||
return { records: value.records.map(normalize), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) }
|
||||
}
|
||||
const base = (vehicleId: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}`
|
||||
export async function listVehicleDeliveries(vehicleId: string, currentPage = 1, size = 10) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<WirePage<VehicleDelivery>>, ApiEnvelope<WirePage<VehicleDelivery>>>(`${base(vehicleId)}/deliveries`, { params: { page: currentPage, size } }))
|
||||
return page(result, row => row)
|
||||
}
|
||||
export async function listVehicleAssignmentChanges(vehicleId: string, currentPage = 1, size = 10) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireChange>>, ApiEnvelope<WirePage<WireChange>>>(`${base(vehicleId)}/assignment-history`, { params: { page: currentPage, size } }))
|
||||
return page(result, row => ({ ...row, recordVersion: String(row.recordVersion) }))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface FleetCustomerOption { id: string; code: string; name: string; contactName: string | null; contactPhone: string | null }
|
||||
export interface TripBookingCargo {
|
||||
destinationKey: string; cargoName: string | null; packageCount: number | null; palletSpaces: number | null;
|
||||
weightKg: number | null; volumeCbm: number | null; priceBasis: 'PALLET' | 'ORDER'; unitPrice: number | null;
|
||||
currency: string | null; contactName: string | null; contactPhone: string | null; remark: string | null;
|
||||
}
|
||||
export interface TripBooking { offerNo: string | null; expectedPalletCount: number | null; bookingNo: string; tripNo: string; customerId: string; customerCode: string; customerName: string;
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'; cargo: TripBookingCargo; destinationLabel: string;
|
||||
agreedAmount: number | null; recordVersion: number; updatedAt: string; incomingNo: string | null; dispatchNo: string | null; receipt: TripAcceptedReceipt | null; quoteSource: import('./api/fleetQuoteCandidates').FleetQuoteSourceSnapshot | null }
|
||||
export interface TripBookingCreate { tripRecordVersion: number; customerId: string; cargo: TripBookingCargo; idempotencyKey: string; quoteRef?: import('./api/fleetQuoteCandidates').FleetQuoteReference | null }
|
||||
export interface TripBookingUpdate { tripRecordVersion: number; recordVersion: number; cargo: TripBookingCargo; quoteRef?: import('./api/fleetQuoteCandidates').FleetQuoteReference | null }
|
||||
export interface TripBookingCommand { tripRecordVersion: number; recordVersion: number; idempotencyKey: string; reason?: string }
|
||||
export interface TripBookingResult { tripRecordVersion: number; booking: TripBooking; replayed: boolean }
|
||||
export interface TripBookingList { tripRecordVersion: number; records: TripBooking[] }
|
||||
export const bookingStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' } as const
|
||||
|
||||
export interface TripIncomingPallet { warehousePalletId: string; palletNo: string; packages: number; weightKg: number | null; volumeCbm: number | null }
|
||||
export interface TripIncomingEvidence { incomingNo: string; inboundCode: string; warehouseId: string; destinationKey: string; status: string; receiptHash: string; pallets: TripIncomingPallet[]; receivedPackages: number }
|
||||
export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number }
|
||||
export interface TripAcceptedReceipt { source: TripIncomingEvidence; pallets: TripPalletSpaces[]; operatorId: string; confirmedAt: string }
|
||||
export interface TripIncomingPreview { tripRecordVersion: number; recordVersion: number; incoming: TripIncomingEvidence | null }
|
||||
export interface TripIncomingStart extends Omit<TripBookingCommand, 'reason'> { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null }
|
||||
export interface TripIncomingAccept extends Omit<TripBookingCommand, 'reason'> { receiptHash: string; pallets: TripPalletSpaces[]; unitPrice: number }
|
||||
export interface TripBookingBind extends Omit<TripBookingCommand, 'reason'> { dispatchNo: string }
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TripBookingCargo } from './bookingTypes'
|
||||
import type { FleetQuoteReference, FleetQuoteSourceSnapshot } from './api/fleetQuoteCandidates'
|
||||
|
||||
export interface OfferPage<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface CapacityLoad { palletSpaces: number; weightKg: number; volumeCbm: number }
|
||||
export interface OfferDestination { destinationKey: string; name: string; deliveryIndex: number; remaining: CapacityLoad | null }
|
||||
export interface OfferSegment { fromIndex: number; toIndex: number; fromName: string; toName: string; planned: CapacityLoad | null; reserved: CapacityLoad | null; remaining: CapacityLoad | null }
|
||||
export interface CapacityOffer {
|
||||
offerNo: string; tripNo: string; status: 'DRAFT' | 'OPEN' | 'PAUSED' | 'CLOSED'; recordVersion: number; tripRecordVersion: number
|
||||
warehouseId: string; warehouseName: string; plannedDepartureAt: string; cutoffAt: string
|
||||
contactName: string | null; contactPhone: string | null; remark: string | null
|
||||
destinations: OfferDestination[]; segments: OfferSegment[]; capacityKnown: boolean; canCreateInquiry: boolean; canConfirm: boolean; blockReason: string | null
|
||||
actions: { canEdit: boolean; canPublish: boolean; canPause: boolean; canResume: boolean; canClose: boolean }
|
||||
}
|
||||
export interface CapacityInquiry {
|
||||
bookingNo: string; offerNo: string; tripNo: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'
|
||||
recordVersion: number; offerRecordVersion: number; tripRecordVersion: number; salesOwnerUserId: string
|
||||
customerId: string; customerCode: string; customerName: string; businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
|
||||
deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE'; estimatedArrivalAt: string | null; expectedPalletCount: number | null
|
||||
cargo: TripBookingCargo; destinationLabel: string; agreedAmount: number | null; quoteSource: FleetQuoteSourceSnapshot | null
|
||||
businessOrderNo: string | null; incomingNo: string | null; dispatchNo: string | null; updatedAt: string
|
||||
actions: { canEdit: boolean; canConfirm: boolean; canCancel: boolean; blockReason: string | null }
|
||||
}
|
||||
export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage<CapacityInquiry> }
|
||||
export interface OfferQuery { scope?: 'AVAILABLE' | 'ALL'; page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
|
||||
export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' }
|
||||
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
|
||||
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
|
||||
export interface InquiryWrite {
|
||||
tripRecordVersion: number; offerRecordVersion: number; recordVersion: number | null; customerId: string
|
||||
businessType: CapacityInquiry['businessType']; deliveryMode: CapacityInquiry['deliveryMode']; estimatedArrivalAt: string | null
|
||||
expectedPalletCount: number | null; cargo: TripBookingCargo; quoteRef: FleetQuoteReference | null; idempotencyKey: string
|
||||
}
|
||||
export interface InquiryCommand extends OfferCommand { recordVersion: number }
|
||||
export interface OfferResult { offer: CapacityOffer; replayed: boolean }
|
||||
export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
|
||||
export interface OfferCustomerOption { id: string; code: string; name: string }
|
||||
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
|
||||
export const inquiryStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' }
|
||||
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
|
||||
@@ -0,0 +1,105 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
|
||||
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
|
||||
import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue'
|
||||
import type { CapacityOffer } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { getCapacityOffer, listCapacityOffers } from '../api/capacityOffers'
|
||||
import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), listCapacityInquiries: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn(), commandCapacityInquiry: vi.fn() }))
|
||||
const offer: CapacityOffer = { offerNo: 'CO-CLOSE', tripNo: 'TR-CLOSE', status: 'OPEN', recordVersion: 1, tripRecordVersion: 2, warehouseId: '9', warehouseName: '测试仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待核对', actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }); vi.mocked(getCapacityOffer).mockResolvedValue({ offer, inquiries: { records: [], total: 0, page: 1, size: 20, pages: 0 } }) })
|
||||
afterEach(async () => { ElMessageBox.close(); wrappers.splice(0).forEach(wrapper => wrapper.unmount()); await flushPromises(); document.body.innerHTML = ''; vi.restoreAllMocks() })
|
||||
function mountDialog(kind: 'inquiry' | 'settings') {
|
||||
const pinia = createPinia()
|
||||
const wrapper = kind === 'inquiry'
|
||||
? mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [pinia, ElementPlus] } })
|
||||
: mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus] } })
|
||||
wrappers.push(wrapper)
|
||||
return { wrapper, pinia }
|
||||
}
|
||||
async function continueEditing() {
|
||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||
expect(message).not.toBeNull()
|
||||
const button = [...message.querySelectorAll('button')].find(node => node.textContent?.trim() === '继续填写')!
|
||||
expect(button).toBeTruthy(); button.click(); await flushPromises()
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); await flushPromises()
|
||||
}
|
||||
it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited input visible through cancel, Escape and X vetoes', async kind => {
|
||||
const { wrapper, pinia } = mountDialog(kind)
|
||||
if (kind === 'settings') await wrapper.setProps({ modelValue: true })
|
||||
await flushPromises()
|
||||
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
await input.setValue('UI-未保存检查'); await flushPromises()
|
||||
const assertPreserved = () => {
|
||||
expect(wrapper.find('.app-dialog').isVisible()).toBe(true)
|
||||
expect(input.element.isConnected).toBe(true)
|
||||
expect(input.element.value).toBe('UI-未保存检查')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1)
|
||||
}
|
||||
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
|
||||
assertPreserved(); await continueEditing(); assertPreserved()
|
||||
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises()
|
||||
assertPreserved(); await continueEditing(); assertPreserved()
|
||||
await wrapper.get('.el-dialog__headerbtn').trigger('click'); await flushPromises()
|
||||
assertPreserved(); await continueEditing(); assertPreserved()
|
||||
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
|
||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||
const accept = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '确认关闭') as HTMLButtonElement
|
||||
accept.click(); await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => {
|
||||
const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm')
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
|
||||
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } },
|
||||
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, meta: { workspaceTab: false } },
|
||||
] })
|
||||
// Match the application's intentional policy: cross-workspace switching skips the global prompt.
|
||||
router.beforeEach(async (to, from) => {
|
||||
const fromId = workspaceRouteId(from)
|
||||
if (!fromId || isWorkspaceRoute(to) && workspaceRouteId(to) !== fromId || !registry.findForWorkspace(fromId).length) return true
|
||||
try { await ElMessageBox.confirm('当前页面有未保存内容', '全局离开确认', { confirmButtonText: '放弃并继续', cancelButtonText: '继续填写' }); registry.removeWorkspaces([fromId]); return true } catch { return false }
|
||||
})
|
||||
const Host = defineComponent({ components: { RouterView }, template: '<RouterView v-slot="{ Component, route }"><KeepAlive><component :is="Component" :key="route.name" /></KeepAlive></RouterView>' })
|
||||
await router.push('/pool'); await router.isReady()
|
||||
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
|
||||
await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
|
||||
const input = wrapper.get<HTMLInputElement>('input[placeholder="选填"]')
|
||||
await input.setValue('UI-修复后保留输入'); await flushPromises()
|
||||
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises(); await continueEditing()
|
||||
confirms.mockClear()
|
||||
router.back(); await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true)
|
||||
await continueEditing()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
confirms.mockClear(); router.back(); await flushPromises()
|
||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||
const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement
|
||||
expect(leave).toBeTruthy(); leave.click(); await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get<HTMLInputElement>('input[placeholder="选填"]').setValue('另一次修改'); await flushPromises()
|
||||
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
|
||||
await continueEditing(); await navigation
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import ElementPlus, { ElMessage } from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
|
||||
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
|
||||
import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes'
|
||||
import type { FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
|
||||
const permissions = vi.hoisted(() => ({ values: new Set<string>() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permissions.values.has(permission) }) }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn() }))
|
||||
const offer: CapacityOffer = { offerNo: 'CO-QUOTE-POSITIVE', tripNo: 'TR-QUOTE-POSITIVE', status: 'OPEN', recordVersion: 4, tripRecordVersion: 7, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: { palletSpaces: 8, weightKg: 1000, volumeCbm: 20 } }], segments: [], capacityKnown: true, canCreateInquiry: true, canConfirm: true, blockReason: null, actions: { canEdit: false, canPublish: false, canPause: false, canResume: false, canClose: false } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(listOfferCustomers).mockResolvedValue([{ id: '19', code: 'C-19', name: '报价测试客户' }])
|
||||
})
|
||||
afterEach(async () => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); ElMessage.closeAll(); await flushPromises(); document.body.innerHTML = '' })
|
||||
function formItem(wrapper: VueWrapper, label: string) { return wrapper.findAll('.el-form-item').find(item => item.find('.el-form-item__label').text() === label)! }
|
||||
async function select(wrapper: VueWrapper, label: string, value: string) {
|
||||
formItem(wrapper, label).findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
await flushPromises()
|
||||
}
|
||||
const scenarios = [
|
||||
{ actor: 'sales', priceBasis: 'PALLET' as const, permissions: ['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:quote:list'] },
|
||||
{ actor: 'manager', priceBasis: 'ORDER' as const, permissions: ['platform:fleet:capacity-offer:manage', 'platform:fleet:quote:list'] },
|
||||
]
|
||||
it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated-price saving for $actor', async scenario => {
|
||||
permissions.values = new Set(scenario.permissions)
|
||||
const source: FleetQuoteSourceSnapshot = { quoteNo: 'FQ-PAGE-2', recordVersion: '9007199254740993', priceItem: scenario.priceBasis === 'ORDER' ? 'FULL_DELIVERY' : 'LOOSE_DELIVERY', customerId: '19', customerName: '报价测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 35, currency: 'CNY', priceBasis: scenario.priceBasis, quotedUnitPrice: 25.5, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
|
||||
vi.mocked(listOfferQuotes).mockResolvedValueOnce({ records: Array.from({ length: 20 }, (_, index) => ({ ...source, quoteNo: `FQ-PAGE-1-${index + 1}` })), total: 21, page: 1, size: 20, pages: 2 }).mockResolvedValueOnce({ records: [source], total: 21, page: 2, size: 20, pages: 2 })
|
||||
vi.mocked(saveCapacityInquiry).mockImplementation(async (_offerNo, _bookingNo, body) => {
|
||||
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
|
||||
return { offer, inquiry, replayed: false }
|
||||
})
|
||||
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
expect(listOfferQuotes).not.toHaveBeenCalled()
|
||||
await select(wrapper, '委托客户', '19'); await select(wrapper, '本车卸货站', 'FBA:ONT8'); await select(wrapper, '币种', 'CNY'); await select(wrapper, '计价方式', scenario.priceBasis)
|
||||
expect(listOfferQuotes).not.toHaveBeenCalled()
|
||||
const quotePanel = wrapper.get('section[aria-label="报价来源"]')
|
||||
await quotePanel.findAll('button').find(button => button.text() === '查询报价库')!.trigger('click'); await flushPromises()
|
||||
const criteria = { customerId: '19', destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, page: 1, size: 20 }
|
||||
expect(listOfferQuotes).toHaveBeenCalledExactlyOnceWith(offer.offerNo, criteria)
|
||||
await quotePanel.findAll('button').find(button => button.text() === '下一页')!.trigger('click'); await flushPromises()
|
||||
expect(listOfferQuotes).toHaveBeenNthCalledWith(2, offer.offerNo, { ...criteria, page: 2 })
|
||||
const reference = { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }
|
||||
quotePanel.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', `${source.quoteNo}:${source.recordVersion}:${source.priceItem}`); await flushPromises()
|
||||
const price = formItem(wrapper, scenario.priceBasis === 'PALLET' ? '每托位单价' : '整单价格').findComponent({ name: 'ElInputNumber' })
|
||||
expect(price.props('modelValue')).toBe(25.5)
|
||||
expect(quotePanel.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe(`${source.quoteNo}:${source.recordVersion}:${source.priceItem}`)
|
||||
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
|
||||
await price.get('input').setValue('21.75'); await flushPromises()
|
||||
expect(price.props('modelValue')).toBe(21.75)
|
||||
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
|
||||
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存意向')!.trigger('click'); await flushPromises()
|
||||
expect(saveCapacityInquiry).toHaveBeenCalledExactlyOnceWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', offerRecordVersion: 4, tripRecordVersion: 7, recordVersion: null, quoteRef: reference, idempotencyKey: expect.any(String), cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, unitPrice: 21.75 }) }))
|
||||
expect(listOfferQuotes).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, toRaw, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
|
||||
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
|
||||
import type { TripBookingCargo } from '../bookingTypes'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean }>()
|
||||
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: [] }>()
|
||||
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
|
||||
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
|
||||
const customerError = ref<ApiErrorDetails | null>(null), customerSearched = ref(false)
|
||||
const quoteRows = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null), quoteSearched = ref(false), quotePage = ref(1), quotePages = ref(0)
|
||||
const pending = ref<{ target: string; body: InquiryWrite } | null>(null)
|
||||
const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo || '')
|
||||
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
|
||||
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
|
||||
const lock = computed(() => !writable.value || busy.value || !!pending.value)
|
||||
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
|
||||
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
|
||||
let generation = 0, customerSequence = 0, quoteSequence = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
function resetQuotes() { quoteSequence++; quoteRows.value = []; quoteSearched.value = false; quoteError.value = null; quoteLoading.value = false; quotePage.value = 1; quotePages.value = 0 }
|
||||
watch(() => [form.customerId, form.cargo.destinationKey, form.cargo.currency, form.cargo.priceBasis], () => { resetQuotes(); form.quoteRef = null; quoteSource.value = null }, { flush: 'sync' })
|
||||
watch(() => [open.value, target.value], async () => {
|
||||
generation++; customerSequence++; clearTimeout(timer); resetQuotes(); customerLoading.value = false
|
||||
if (!open.value) return
|
||||
if (pending.value && pending.value.target !== target.value) { ElMessage.warning('请先核对或重试上一条结果未知的意向'); open.value = false; return }
|
||||
error.value = null; customerError.value = null; customerSearched.value = false; customers.value = []
|
||||
if (pending.value) Object.assign(form, structuredClone(toRaw(pending.value.body)))
|
||||
else {
|
||||
const row = props.inquiry
|
||||
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
|
||||
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
|
||||
}
|
||||
await nextTick(); guard.capture()
|
||||
}, { immediate: true })
|
||||
function searchCustomers(keyword = '') {
|
||||
if (lock.value || props.inquiry) return
|
||||
clearTimeout(timer); const current = ++customerSequence
|
||||
timer = setTimeout(async () => {
|
||||
customerLoading.value = true; customerError.value = null
|
||||
try { const rows = await listOfferCustomers(keyword.trim() || undefined); if (current === customerSequence && open.value) { customers.value = rows; customerSearched.value = true } }
|
||||
catch (cause) { if (current === customerSequence) { customers.value = []; customerError.value = getApiErrorDetails(cause, '客户候选加载失败') } }
|
||||
finally { if (current === customerSequence) customerLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
async function queryQuotes(page = 1) {
|
||||
if (!props.offer || lock.value || !canQuote.value || !quoteReady.value) return
|
||||
const current = ++quoteSequence; quoteLoading.value = true; quoteError.value = null
|
||||
try { const value = await listOfferQuotes(props.offer.offerNo, { customerId: form.customerId, destinationKey: form.cargo.destinationKey, currency: form.cargo.currency as 'USD' | 'CNY', priceBasis: form.cargo.priceBasis, page, size: 20 }); if (current === quoteSequence && open.value) { quoteRows.value = value.records; quotePage.value = value.page; quotePages.value = value.pages; quoteSearched.value = true } }
|
||||
catch (cause) { if (current === quoteSequence) quoteError.value = getApiErrorDetails(cause, '报价候选读取失败,可继续手工询价') }
|
||||
finally { if (current === quoteSequence) quoteLoading.value = false }
|
||||
}
|
||||
const sourceKey = (row: FleetQuoteReference) => `${row.quoteNo}:${row.recordVersion}:${row.priceItem}`
|
||||
function selectQuote(key: string) { const row = quoteRows.value.find(item => sourceKey(item) === key); if (row && !lock.value) { quoteSource.value = row; form.quoteRef = fleetQuoteReference(row); form.cargo.unitPrice = row.quotedUnitPrice } }
|
||||
async function save() {
|
||||
if (busy.value || !writable.value || !props.offer) return
|
||||
if (!form.customerId || !form.cargo.destinationKey) { ElMessage.warning('请选择客户和本车卸货站'); return }
|
||||
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
pending.value ||= { target: target.value, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
|
||||
const result = await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
|
||||
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success('意向已保存,尚未占用运力') }
|
||||
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
|
||||
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
|
||||
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
|
||||
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
|
||||
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
|
||||
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
||||
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
|
||||
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
|
||||
<el-form-item label="预计到仓"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="计价方式"><el-select v-model="form.cargo.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
|
||||
<el-form-item :label="form.cargo.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.cargo.unitPrice" :min="0" :precision="2" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="币种"><el-select v-model="form.cargo.currency" filterable allow-create><el-option v-for="currency in ['USD', 'CNY', 'CAD', 'EUR', 'GBP']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="form.cargo.contactName" placeholder="确认承运前补充" /></el-form-item>
|
||||
<el-form-item label="联系方式"><el-input v-model="form.cargo.contactPhone" placeholder="确认承运前补充" /></el-form-item>
|
||||
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="customerError" title="客户候选读取失败" :message="customerError.message" @retry="searchCustomers()"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
|
||||
<section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
|
||||
<template v-if="canQuote && writable"><el-button :disabled="lock || !quoteReady" :loading="quoteLoading" @click="queryQuotes()">查询报价库</el-button><el-button v-if="form.quoteRef" :disabled="lock" @click="form.quoteRef = null; quoteSource = null">改为手工询价</el-button>
|
||||
<el-select v-if="quoteRows.length" :model-value="form.quoteRef ? sourceKey(form.quoteRef) : ''" :disabled="lock" placeholder="选择报价,可另行议价" @update:model-value="selectQuote"><el-option v-for="quote in quoteRows" :key="sourceKey(quote)" :value="sourceKey(quote)" :label="`${quote.quoteNo} · ${quote.quotedUnitPrice} ${quote.currency}`" /></el-select>
|
||||
<p v-if="quoteSearched && !quoteRows.length">暂无适用报价,可继续手工询价。</p><div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
|
||||
</template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p>
|
||||
</section>
|
||||
<p v-if="inquiry">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { inquiryStatusLabels, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
|
||||
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
|
||||
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
|
||||
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
const manage = access.can('platform:fleet:capacity-offer:manage')
|
||||
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
|
||||
return [{ key: 'view', label: '详情', priority: 10 },
|
||||
{ key: 'edit', label: '编辑', visible: row.actions.canEdit && write('edit'), disabled: busy },
|
||||
{ key: 'confirm', label: '确认承运', visible: row.actions.canConfirm && manage, disabled: busy },
|
||||
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210 }, { key: 'customerName', label: '客户', minWidth: 155 },
|
||||
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
|
||||
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
|
||||
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
|
||||
]
|
||||
</script>
|
||||
<template><DataTable :appearance="integrated ? 'integrated' : 'default'" :stripe="false" :results-visible="!failed" preference-key="fleet-capacity-inquiries" column-settings-mode="show" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="bookingNo" empty-text="当前范围暂无意向">
|
||||
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
|
||||
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
|
||||
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||
<template #status="{ row }">{{ inquiryStatusLabels[(row as CapacityInquiry).status] }}<small v-if="row.actions.blockReason" class="inquiry-reason">{{ row.actions.blockReason }}</small></template>
|
||||
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
|
||||
</DataTable></template>
|
||||
<style scoped>.inquiry-reason{display:block;color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal}</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listCapacityOffers, saveCapacityOffer, commandCapacityOffer } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, type CapacityOffer, type OfferWrite, type OfferCommand } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: FleetTrip }>()
|
||||
const emit = defineEmits<{ saved: [version: number] }>()
|
||||
const access = useAccessPreviewStore(), offer = ref<CapacityOffer | null>(null), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const loaded = ref(false)
|
||||
const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark: '' })
|
||||
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
|
||||
let generation = 0
|
||||
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, computed(() => !!pending.value))
|
||||
async function load() {
|
||||
if (!writable.value || !open.value || pending.value) return
|
||||
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
|
||||
try { const value = await listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
watch(open, value => { generation++; if (value) { if (pending.value) return; void load() } })
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value || !writable.value) return
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try { const p = pending.value; const result = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body); if (current !== generation) return; offer.value = result.offer; pending.value = null; emit('saved', result.offer.tripRecordVersion); guard.capture(); ElMessage.success('发布设置已更新') }
|
||||
catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '发布操作未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (pending.value) { await execute(); return }
|
||||
if (loading.value || !loaded.value || !writable.value || offer.value && !offer.value.actions.canEdit) return
|
||||
if (!form.cutoffAt) { ElMessage.warning('请填写交货截止时间'); return }
|
||||
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: props.trip.tripNo, tripRecordVersion: offer.value?.tripRecordVersion ?? props.trip.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
|
||||
await execute()
|
||||
}
|
||||
async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
|
||||
if (!offer.value || loading.value || !loaded.value || busy.value || pending.value || !writable.value) return
|
||||
if (guard.dirty.value) { ElMessage.warning('请先保存发布信息,再执行发布状态操作'); return }
|
||||
const labels = { publish: '发布运力', pause: '暂停接单', resume: '恢复接单', close: '关闭发布' }
|
||||
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
pending.value = { action, offerNo: offer.value.offerNo, body: { tripRecordVersion: offer.value.tripRecordVersion, offerRecordVersion: offer.value.recordVersion, idempotencyKey: crypto.randomUUID() } }; await execute()
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布设置" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '保存发布信息'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
|
||||
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">
|
||||
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /></el-form-item><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPublish" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('publish')">发布运力</el-button><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canResume" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('resume')">重新核对并恢复</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
|
||||
</AppDialog></template>
|
||||
<style scoped>.offer-settings-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.offer-settings-wide{grid-column:1/-1}.offer-settings-form :deep(.el-date-editor){width:100%}</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { createDriver, listDriverWarehouses, updateDriver, uploadDriverLicense } from '../api/drivers'
|
||||
import type { DriverDetail, DriverDraft, DriverProfile, DriverWarehouseOption } from '../driverTypes'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ driver: DriverDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [driver: DriverDetail] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const saving = ref(false), error = ref<ApiErrorDetails | null>(null), warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseLoading = ref(false), warehouseOptions = ref<DriverWarehouseOption[]>([])
|
||||
const current = ref<DriverDetail | null>(null), selectedFile = ref<File | null>(null), fileInput = ref<HTMLInputElement | null>(null)
|
||||
const emptyProfile = (): DriverProfile => ({ warehouseId: null, vehicleModels: null, languagesAndArea: null, medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null })
|
||||
const form = reactive<DriverDraft>({ name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
|
||||
const profile = computed(() => form.profile!)
|
||||
const zones = ['America/Los_Angeles', 'America/New_York', 'America/Chicago', 'America/Denver', 'Asia/Shanghai', 'Europe/London', 'UTC']
|
||||
let warehouseRequest = 0, editSession = 0, lastSaved = ''
|
||||
async function loadWarehouses() {
|
||||
const request = ++warehouseRequest
|
||||
warehouseLoading.value = true; warehouseError.value = null
|
||||
try { const rows = await listDriverWarehouses(form.region); if (request === warehouseRequest) warehouseOptions.value = rows }
|
||||
catch (cause) { if (request === warehouseRequest) { warehouseOptions.value = []; warehouseError.value = getApiErrorDetails(cause, '仓库候选加载失败') } }
|
||||
finally { if (request === warehouseRequest) warehouseLoading.value = false }
|
||||
}
|
||||
function changeRegion() { profile.value.warehouseId = null; void loadWarehouses() }
|
||||
watch([open, () => props.driver?.id], ([value]) => {
|
||||
editSession++; saving.value = false
|
||||
if (!value) { warehouseRequest++; return }
|
||||
current.value = props.driver; selectedFile.value = null; error.value = null; lastSaved = ''
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
Object.assign(form, props.driver ? { ...props.driver.details, profile: { ...emptyProfile(), ...props.driver.details.profile } }
|
||||
: { name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
|
||||
void loadWarehouses()
|
||||
})
|
||||
function selectFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] || null
|
||||
if (file && (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
|
||||
ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); input.value = ''; selectedFile.value = null; return
|
||||
}
|
||||
selectedFile.value = file
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
if (!form.name.trim() || !form.phone.trim() || !form.licenseZone.trim()) { ElMessage.warning('请填写姓名、电话和证照时区'); return }
|
||||
saving.value = true; error.value = null
|
||||
const session = editSession, sourceDriverId = props.driver?.id
|
||||
const isCurrent = () => session === editSession && open.value && props.driver?.id === sourceDriverId
|
||||
let persisted = false
|
||||
try {
|
||||
const details: DriverDraft = { ...form, name: form.name.trim(), phone: form.phone.trim(), profile: { ...profile.value } }
|
||||
const fingerprint = JSON.stringify(details)
|
||||
if (!current.value || fingerprint !== lastSaved) {
|
||||
const savedDriver = current.value ? await updateDriver(current.value.id, current.value.recordVersion, details) : await createDriver(details)
|
||||
if (!isCurrent()) return
|
||||
current.value = savedDriver
|
||||
lastSaved = fingerprint
|
||||
}
|
||||
persisted = true
|
||||
if (selectedFile.value && current.value) {
|
||||
const uploadedDriver = await uploadDriverLicense(current.value.id, current.value.recordVersion, selectedFile.value)
|
||||
if (!isCurrent()) return
|
||||
current.value = uploadedDriver
|
||||
selectedFile.value = null
|
||||
}
|
||||
if (current.value) emit('saved', current.value)
|
||||
ElMessage.success('司机档案已保存'); open.value = false
|
||||
} catch (cause) {
|
||||
if (!isCurrent()) return
|
||||
if (persisted && current.value) emit('saved', current.value)
|
||||
const details = getApiErrorDetails(cause, '司机保存失败')
|
||||
error.value = { ...details, message: persisted ? `档案已保存,驾驶证上传失败:${details.message}。可直接重试。` : details.message }
|
||||
} finally { if (isCurrent()) saving.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { warehouseRequest++; editSession++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="current ? '编辑司机档案' : '新增司机档案'" width="1080px" confirm-text="保存司机" :confirm-loading="saving" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form driver-form" :disabled="saving" @submit.prevent="save">
|
||||
<h3 class="full-width">基础信息</h3>
|
||||
<el-form-item label="司机姓名" required><el-input v-model="form.name" maxlength="128" placeholder="请输入司机姓名" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="form.phone" maxlength="64" placeholder="请输入联系电话" /></el-form-item>
|
||||
<el-form-item label="司机归属"><el-select v-model="form.ownership"><el-option label="自有司机" value="OWNED" /><el-option label="外派司机" value="DISPATCHED" /></el-select></el-form-item>
|
||||
<el-form-item label="区域"><el-select v-model="form.region" @change="changeRegion"><el-option label="国内" value="DOMESTIC" /><el-option label="海外" value="OVERSEAS" /></el-select></el-form-item>
|
||||
<el-form-item label="所属仓库"><el-select v-model="profile.warehouseId" clearable filterable :loading="warehouseLoading" placeholder="选择所属仓库"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" :value="warehouse.id" /></el-select></el-form-item>
|
||||
<el-form-item label="可驾驶车型"><el-input v-model="profile.vehicleModels" maxlength="128" placeholder="如 53 尺拖车" /></el-form-item>
|
||||
<LoadErrorState v-if="warehouseError" class="full-width" title="仓库候选加载失败" :message="warehouseError.message" @retry="loadWarehouses" ><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<el-form-item label="语言 / 服务区域" class="full-width"><el-input v-model="profile.languagesAndArea" maxlength="256" placeholder="如 中文、英语 / 洛杉矶区域" /></el-form-item>
|
||||
<h3 class="full-width">驾驶资质</h3>
|
||||
<el-form-item label="驾照类型"><el-select v-model="form.licenseType" filterable allow-create default-first-option clearable><el-option v-for="kind in ['Class A', 'Class B', 'Class C', '普通驾照']" :key="kind" :label="kind" :value="kind" /></el-select></el-form-item>
|
||||
<el-form-item label="驾照编号"><el-input v-model="form.licenseNumber" maxlength="128" placeholder="请输入驾驶证编号" /></el-form-item>
|
||||
<el-form-item label="证照有效时区" required><el-select v-model="form.licenseZone" filterable allow-create default-first-option placeholder="请选择或输入 IANA 时区"><el-option v-for="zone in zones" :key="zone" :label="zone" :value="zone" /></el-select></el-form-item>
|
||||
<el-form-item label="驾照签发日"><el-date-picker v-model="form.licenseIssuedOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="驾照到期日"><el-date-picker v-model="form.licenseExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="体检到期日"><el-date-picker v-model="profile.medicalExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="培训 / 安全到期日"><el-date-picker v-model="profile.trainingExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="保险到期日"><el-date-picker v-model="profile.insuranceExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<p class="full-width form-hint">驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p>
|
||||
<h3 class="full-width">证照附件</h3>
|
||||
<div class="full-width">
|
||||
<label v-if="access.can(permission.driverUpload)">驾驶证(PDF / JPG / PNG,最大 10MiB)<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
|
||||
<p class="form-hint">{{ selectedFile?.name || (current?.licenseFileId ? '已有驾驶证附件;上传新文件后需要重新审核。' : '可先保存档案,补齐驾驶证后才能通过审核。') }}</p>
|
||||
</div>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充司机档案说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.driver-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4) var(--admin-space-6); }
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.driver-form h3 { margin: 0; }
|
||||
.driver-form :deep(.el-select), .driver-form :deep(.el-date-editor) { width: 100%; }
|
||||
.form-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
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 { downloadDriverLicense } from '../api/drivers'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ driverId: string; fileId: string; driverName: string }>()
|
||||
const url = ref(''), contentType = ref(''), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0
|
||||
function release() { if (url.value) URL.revokeObjectURL(url.value); url.value = '' }
|
||||
async function load() {
|
||||
const current = ++request
|
||||
release(); error.value = null; loading.value = true
|
||||
try {
|
||||
const blob = await downloadDriverLicense(props.driverId, props.fileId)
|
||||
if (current !== request || !open.value) return
|
||||
contentType.value = blob.type; url.value = URL.createObjectURL(blob)
|
||||
} catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '驾驶证读取失败') }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
watch([open, () => props.driverId, () => props.fileId], () => {
|
||||
if (open.value && props.driverId && props.fileId) void load()
|
||||
else { request++; release(); loading.value = false }
|
||||
})
|
||||
onBeforeUnmount(() => { request++; release() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="驾驶证附件" width="620px" :show-confirm="false" cancel-text="关闭">
|
||||
<p>{{ driverName }} · 驾驶证附件</p>
|
||||
<LoadErrorState v-if="error" title="驾驶证读取失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" class="license-preview">
|
||||
<iframe v-if="url && contentType === 'application/pdf'" :src="url" title="驾驶证 PDF 预览" />
|
||||
<img v-else-if="url" :src="url" :alt="`${driverName}的驾驶证`" />
|
||||
</div>
|
||||
<a v-if="url" :href="url" :download="`驾驶证.${contentType === 'application/pdf' ? 'pdf' : contentType === 'image/png' ? 'png' : 'jpg'}`">下载驾驶证</a>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.license-preview { min-height: 160px; }
|
||||
.license-preview iframe { width: 100%; height: 560px; border: 0; }
|
||||
.license-preview img { display: block; max-width: 100%; max-height: 560px; margin: auto; object-fit: contain; }
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import FleetQuoteEditor from './FleetQuoteEditor.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/fleetQuotes'
|
||||
vi.mock('../api/fleetQuotes', async original => ({ ...(await original<typeof import('../api/fleetQuotes')>()), createFleetQuote: vi.fn(), updateFleetQuote: vi.fn() }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
const input = (name: string) => ({ name, props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" />' })
|
||||
const slot = { template: '<div><slot/></div>' }
|
||||
async function mount() {
|
||||
const wrapper = shallowMount(FleetQuoteEditor, { props: { modelValue: false, quote: null, kind: 'SALE' }, global: { stubs: {
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot/><slot name="footer"/></div>' }, ElForm: { name: 'ElForm', props: ['disabled'], template: '<div><slot/></div>' },
|
||||
ElFormItem: slot, ElInput: input('ElInput'), ElInputNumber: input('ElInputNumber'), ElDatePicker: input('ElDatePicker'), ElSelect: slot, ElOption: true,
|
||||
FleetQuotePartySelect: input('FleetQuotePartySelect'), ElButton: { template: '<button><slot/></button>' }, ElAlert: { props: ['title'], template: '<p>{{ title }}<slot/></p>' }, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
wrapper.findComponent({ name: 'FleetQuotePartySelect' }).vm.$emit('update:modelValue', '101')
|
||||
wrapper.findAllComponents({ name: 'ElInput' })[1]!.vm.$emit('update:modelValue', 'QA-Origin')
|
||||
wrapper.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', 'QA-DEST')
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 10)
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[2]!.vm.$emit('update:modelValue', 0)
|
||||
wrapper.findAllComponents({ name: 'ElDatePicker' })[1]!.vm.$emit('update:modelValue', '2100-01-01')
|
||||
await flushPromises(); return wrapper
|
||||
}
|
||||
async function clickSave(wrapper: VueWrapper) { await wrapper.findAll('button').find(b => ['保存报价', '按原请求重试'].includes(b.text()))!.trigger('click'); await flushPromises() }
|
||||
beforeEach(() => vi.clearAllMocks()); afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it('HTTP 200/A0409 unlocks the form so corrected content receives a new key', async () => {
|
||||
const wrapper = await mount(); vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new ApiResponseError('报价组合重复', 'A0409', 200))
|
||||
await clickSave(wrapper); const original = vi.mocked(api.createFleetQuote).mock.calls[0]!
|
||||
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(false); expect(wrapper.text()).not.toContain('按原请求重试')
|
||||
wrapper.findAllComponents({ name: 'ElInput' })[1]!.vm.$emit('update:modelValue', 'QA-Another-Origin')
|
||||
vi.mocked(api.createFleetQuote).mockResolvedValue({ quoteNo: 'FQ-NEW', details: original[1], recordVersion: '0', partyName: '测试客户', status: 'ACTIVE', updatedAt: '' })
|
||||
await clickSave(wrapper)
|
||||
const corrected = vi.mocked(api.createFleetQuote).mock.calls[1]!
|
||||
expect(corrected[0]).not.toBe(original[0]); expect(corrected[1].originCity).toBe('QA-Another-Origin'); expect(corrected[1].fullDeliveryPrice).toBe(0)
|
||||
})
|
||||
it('unknown creation retains its exact key after viewing another quote', async () => {
|
||||
const wrapper = await mount(); vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new Error('Network unavailable'))
|
||||
await clickSave(wrapper); const original = vi.mocked(api.createFleetQuote).mock.calls[0]!
|
||||
const other: api.FleetQuote = { quoteNo: 'FQ-OTHER', details: { ...original[1], originCity: 'OTHER' }, recordVersion: '2', partyName: '测试客户', status: 'ACTIVE', updatedAt: '' }
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ quote: other, readonly: true, modelValue: true }); await flushPromises()
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ quote: null, readonly: false, modelValue: true }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('按原请求重试'); vi.mocked(api.createFleetQuote).mockResolvedValue({ ...other, quoteNo: 'FQ-NEW', details: original[1] })
|
||||
await clickSave(wrapper); expect(vi.mocked(api.createFleetQuote).mock.calls[1]).toEqual(original)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
|
||||
import { createFleetQuote, updateFleetQuote, isFleetCommandRejected, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ quote: FleetQuote | null; readonly?: boolean; kind: FleetQuoteKind }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
type QuoteForm = Omit<FleetQuoteDraft, 'distanceMiles'> & { distanceMiles: number | null }
|
||||
const empty = (): QuoteForm => ({ kind: props.kind, partyId: '', originCity: '', destinationCode: '', distanceMiles: null, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: null, looseDeliveryPrice: null, validFrom: new Date().toISOString().slice(0, 10), validTo: '', remark: null })
|
||||
const form = reactive<QuoteForm>(empty()), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let pending: { idempotencyKey: string; details: FleetQuoteDraft } | null = null
|
||||
const uncertain = ref(false)
|
||||
watch([open, () => props.quote?.quoteNo], () => {
|
||||
if (!open.value) return
|
||||
uncertain.value = !props.quote && pending !== null
|
||||
Object.assign(form, props.quote?.details || pending?.details || empty()); error.value = null
|
||||
})
|
||||
async function save() {
|
||||
if (props.readonly || saving.value) return
|
||||
const editing = props.quote
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
if (!form.partyId || !form.originCity.trim() || !form.destinationCode.trim() || !form.validFrom || !form.validTo) throw new Error('请选择往来单位并填写路线和有效期')
|
||||
if (form.distanceMiles == null || !Number.isFinite(form.distanceMiles)) throw new Error('请填写有效的预估距离')
|
||||
if (form.selfPickupPrice == null && form.fullDeliveryPrice == null && form.looseDeliveryPrice == null) throw new Error('至少填写一种价格,零表示明确零价')
|
||||
const details: FleetQuoteDraft = { ...form, distanceMiles: form.distanceMiles }
|
||||
if (editing) await updateFleetQuote(editing, details)
|
||||
else { pending ??= { idempotencyKey: crypto.randomUUID(), details }; await createFleetQuote(pending.idempotencyKey, pending.details) }
|
||||
if (!editing) pending = null
|
||||
uncertain.value = false; open.value = false; emit('saved')
|
||||
} catch (e) { error.value = getApiErrorDetails(e, '报价保存失败'); if (!editing && isFleetCommandRejected(e)) pending = null; uncertain.value = !editing && pending !== null }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
<template><AppDialog v-model="open" :title="readonly ? '报价详情' : quote ? '编辑报价' : '新增报价'" width="780px" class="admin-density-dialog" :close-on-click-modal="false">
|
||||
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
|
||||
<p v-if="uncertain">保存结果尚未确认,重试将发送原请求和原内容。</p>
|
||||
<el-form label-position="top" class="density-form quote-form" :disabled="readonly || saving || uncertain">
|
||||
<el-form-item label="用途"><el-input :model-value="form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'" disabled /></el-form-item>
|
||||
<el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="readonly || saving || uncertain" /></el-form-item>
|
||||
<el-form-item label="发货城市" required><el-input v-model="form.originCity" maxlength="128" placeholder="请输入发货城市" /></el-form-item>
|
||||
<el-form-item label="目的仓码" required><el-input v-model="form.destinationCode" maxlength="64" placeholder="请输入目的仓码,如 ONT8" /></el-form-item>
|
||||
<el-form-item label="预估距离(mile)" required><el-input-number :model-value="form.distanceMiles" @update:model-value="form.distanceMiles = $event ?? null" :min="0" :precision="1" placeholder="请输入预估英里数" /></el-form-item>
|
||||
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
|
||||
<el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
|
||||
<el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
|
||||
<el-form-item label="生效日" required><el-date-picker v-model="form.validFrom" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="失效日(含当天)" required><el-date-picker v-model="form.validTo" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充报价说明" /></el-form-item>
|
||||
</el-form><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p>
|
||||
<template #footer><el-button @click="open = false">关闭</el-button><el-button v-if="!readonly" type="primary" :loading="saving" @click="save">{{ uncertain ? '按原请求重试' : '保存报价' }}</el-button></template>
|
||||
</AppDialog></template>
|
||||
<style scoped>.quote-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import FleetQuoteImport from './FleetQuoteImport.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { fleetQuoteCsvHeader } from '../fleetQuoteCsv'
|
||||
import * as api from '../api/fleetQuotes'
|
||||
vi.mock('../api/fleetQuotes', async original => ({ ...(await original<typeof import('../api/fleetQuotes')>()), importFleetQuotes: vi.fn() }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
async function mount() {
|
||||
const wrapper = shallowMount(FleetQuoteImport, { props: { modelValue: true, kind: 'SALE' }, global: { stubs: {
|
||||
AppDialog: { template: '<div><slot/><slot name="footer"/></div>' },
|
||||
FleetQuotePartySelect: { name: 'FleetQuotePartySelect', props: ['modelValue', 'kind', 'disabled'], emits: ['update:modelValue'], template: '<div/>' },
|
||||
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' },
|
||||
ElAlert: { props: ['title'], template: '<p>{{ title }}<slot/></p>' }, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper)
|
||||
wrapper.findComponent({ name: 'FleetQuotePartySelect' }).vm.$emit('update:modelValue', '101'); await flushPromises(); return wrapper
|
||||
}
|
||||
async function pick(wrapper: VueWrapper, city = 'QA-FIRST', size = 100, text?: () => Promise<string>) {
|
||||
Object.defineProperty(wrapper.find('input[type="file"]').element, 'files', { configurable: true, value: [{ size, text: text || (async () => `${fleetQuoteCsvHeader.join(',')}\n${city},QA-DEST,10,USD,,0,12,2026-01-01,2100-01-01,`) }] })
|
||||
await wrapper.find('input[type="file"]').trigger('change'); await flushPromises()
|
||||
}
|
||||
async function save(wrapper: VueWrapper) { await wrapper.findAll('button').find(b => ['确认导入', '按原请求重试'].includes(b.text()))!.trigger('click'); await flushPromises() }
|
||||
beforeEach(() => vi.clearAllMocks()); afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it('definite import rejection unlocks file selection and corrected rows use a new key', async () => {
|
||||
const wrapper = await mount(); await pick(wrapper)
|
||||
vi.mocked(api.importFleetQuotes).mockRejectedValueOnce(new ApiResponseError('组合重复', 'A0409', 200)); await save(wrapper)
|
||||
const first = vi.mocked(api.importFleetQuotes).mock.calls[0]!
|
||||
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeUndefined()
|
||||
await pick(wrapper, 'QA-CORRECTED'); vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper)
|
||||
const second = vi.mocked(api.importFleetQuotes).mock.calls[1]!
|
||||
expect(second[0]).not.toBe(first[0]); expect(second[1][0]?.originCity).toBe('QA-CORRECTED')
|
||||
})
|
||||
it('unknown result preserves original purpose, key and rows across closing and category changes', async () => {
|
||||
const wrapper = await mount(); await pick(wrapper)
|
||||
vi.mocked(api.importFleetQuotes).mockRejectedValueOnce(new Error('network')); await save(wrapper)
|
||||
const call = vi.mocked(api.importFleetQuotes).mock.calls[0]!
|
||||
const first = [call[0], call[1].map(row => ({ ...row }))]
|
||||
await wrapper.setProps({ modelValue: false, kind: 'PURCHASE' }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FleetQuotePartySelect' }).props('kind')).toBe('SALE')
|
||||
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined()
|
||||
vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper)
|
||||
expect(vi.mocked(api.importFleetQuotes).mock.calls[1]).toEqual(first)
|
||||
})
|
||||
it('selecting an oversized replacement file cannot accidentally submit the previous valid file', async () => {
|
||||
const wrapper = await mount(); await pick(wrapper); await pick(wrapper, 'TOO-LARGE', 500001)
|
||||
expect(wrapper.findAll('button').find(b => b.text() === '确认导入')!.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('QA-FIRST'); expect(api.importFleetQuotes).not.toHaveBeenCalled()
|
||||
})
|
||||
it('a late old file read cannot replace the most recently selected CSV', async () => {
|
||||
const wrapper = await mount(); let release!: (value: string) => void
|
||||
const slow = new Promise<string>(resolve => { release = resolve })
|
||||
await pick(wrapper, 'SLOW', 100, () => slow)
|
||||
await pick(wrapper, 'QA-LATEST')
|
||||
release(`${fleetQuoteCsvHeader.join(',')}\nQA-OLD,QA-DEST,10,USD,,0,12,2026-01-01,2100-01-01,`); await flushPromises()
|
||||
expect(wrapper.text()).toContain('QA-LATEST'); expect(wrapper.text()).not.toContain('QA-OLD')
|
||||
vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper)
|
||||
expect(vi.mocked(api.importFleetQuotes).mock.calls[0]?.[1][0]?.originCity).toBe('QA-LATEST')
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
|
||||
import { importFleetQuotes, saveFleetCsv, isFleetCommandRejected, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
|
||||
import { fleetQuoteCsvHeader, parseFleetQuoteCsv } from '../fleetQuoteCsv'
|
||||
const open = defineModel<boolean>({ default: false }), props = defineProps<{ kind: FleetQuoteKind }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const partyId = ref(''), rows = ref<FleetQuoteDraft[]>([]), source = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<{ key: string; rows: FleetQuoteDraft[] } | null>(null)
|
||||
const activeKind = computed(() => pending.value?.rows[0]?.kind || props.kind)
|
||||
let fileSequence = 0
|
||||
watch([partyId, () => props.kind], () => { if (!pending.value) preview() })
|
||||
function preview() { try { rows.value = source.value ? parseFleetQuoteCsv(source.value, props.kind, partyId.value) : []; error.value = null } catch (e) { rows.value = []; error.value = getApiErrorDetails(e, 'CSV校验失败') } }
|
||||
async function pick(event: Event) {
|
||||
if (saving.value || pending.value) return
|
||||
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return
|
||||
const current = ++fileSequence; rows.value = []; source.value = ''; error.value = null
|
||||
if (file.size > 500000) { error.value = getApiErrorDetails(new Error('CSV不能超过500KB')); return }
|
||||
try { const text = await file.text(); if (current !== fileSequence) return; source.value = text; preview() }
|
||||
catch (e) { if (current === fileSequence) error.value = getApiErrorDetails(e, 'CSV读取失败,请重新选择文件') }
|
||||
}
|
||||
function template() { saveFleetCsv(new Blob(['\uFEFF' + fleetQuoteCsvHeader.join(',') + '\r\n'], { type: 'text/csv;charset=utf-8' }), '车队报价模板.csv') }
|
||||
async function save() { if (saving.value || (!pending.value && (!rows.value.length || !partyId.value))) return; fileSequence++; saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (isFleetCommandRejected(e)) pending.value = null } finally { saving.value = false } }
|
||||
onBeforeUnmount(() => { fileSequence++ })
|
||||
</script>
|
||||
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog" :close-on-click-modal="false">
|
||||
<p>本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
|
||||
<FleetQuotePartySelect v-model="partyId" :kind="activeKind" :disabled="saving || !!pending" />
|
||||
<el-button @click="template">下载CSV模板</el-button><input type="file" accept=".csv" aria-label="选择报价CSV" :disabled="saving || !!pending" @change="pick" />
|
||||
<el-alert v-if="error" type="error" :title="error.message" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
|
||||
<p v-if="pending">已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。</p>
|
||||
<p aria-live="polite">共 {{ rows.length }} 条,预览前10条;目录有效性与重复组合由服务端核对。</p>
|
||||
<table class="density-table"><thead><tr><th>发货城市</th><th>目的仓码</th><th>距离(mile)</th><th>币种</th><th>整车自提</th><th>整车派送</th><th>散板派送</th><th>有效期</th></tr></thead><tbody><tr v-for="(row, index) in rows.slice(0,10)" :key="index"><td>{{ row.originCity }}</td><td>{{ row.destinationCode }}</td><td>{{ row.distanceMiles }}</td><td>{{ row.currency }}</td><td>{{ row.selfPickupPrice ?? '—' }}</td><td>{{ row.fullDeliveryPrice ?? '—' }}</td><td>{{ row.looseDeliveryPrice ?? '—' }}</td><td>{{ row.validFrom }} — {{ row.validTo }}</td></tr></tbody></table>
|
||||
<template #footer><el-button @click="open = false">关闭</el-button><el-button type="primary" :disabled="!rows.length || !partyId" :loading="saving" @click="save">{{ pending ? '按原请求重试' : '确认导入' }}</el-button></template>
|
||||
</AppDialog></template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { fleetQuoteCustomers, fleetQuoteSuppliers, type FleetQuoteKind } from '../api/fleetQuotes'
|
||||
const value = defineModel<string>({ default: '' })
|
||||
const props = defineProps<{ kind: FleetQuoteKind; partyName?: string; disabled?: boolean }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<{ id: string; name: string }[]>([]), loading = ref(false), error = ref('')
|
||||
let sequence = 0
|
||||
watch(() => props.kind, () => { rows.value = []; error.value = ''; sequence++ })
|
||||
async function search(keyword = '') {
|
||||
if (props.disabled || (props.kind === 'PURCHASE' && !access.can('platform:supplier:list'))) return
|
||||
const current = ++sequence; loading.value = true; error.value = ''
|
||||
try { const result = props.kind === 'SALE' ? await fleetQuoteCustomers(keyword) : await fleetQuoteSuppliers(keyword); if (sequence === current) rows.value = result }
|
||||
catch { if (sequence === current) error.value = '目录加载失败,请重新搜索' } finally { if (sequence === current) loading.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template><el-select v-model="value" filterable remote :remote-method="search" :loading="loading" :disabled="disabled || (kind === 'PURCHASE' && !access.can('platform:supplier:list'))" placeholder="搜索并选择往来单位" @focus="search()">
|
||||
<el-option v-if="value && partyName && !rows.some(r => r.id === value)" :value="value" :label="partyName" />
|
||||
<el-option v-for="row in rows" :key="row.id" :value="row.id" :label="row.name" />
|
||||
</el-select><p v-if="error" role="alert">{{ error }}</p><p v-if="kind === 'PURCHASE' && !access.can('platform:supplier:list')">选择车队需要供应商列表权限。</p></template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { assignTrip } from '../api/trips'
|
||||
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
const props = defineProps<{ detail: TripDetail }>()
|
||||
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||||
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
|
||||
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let vehicleRequest = 0, driverRequest = 0, generation = 0
|
||||
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(() => props.detail.trip, trip => {
|
||||
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
|
||||
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
|
||||
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
|
||||
}, { immediate: true })
|
||||
function searchVehicles(keyword = '') {
|
||||
clearTimeout(vehicleTimer); const request = ++vehicleRequest
|
||||
vehicleTimer = setTimeout(async () => {
|
||||
vehicleLoading.value = true
|
||||
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (request === vehicleRequest) vehicles.value = result.records }
|
||||
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
|
||||
finally { if (request === vehicleRequest) vehicleLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
function searchDrivers(keyword = '') {
|
||||
clearTimeout(driverTimer); const request = ++driverRequest
|
||||
driverTimer = setTimeout(async () => {
|
||||
driverLoading.value = true
|
||||
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt }); if (request === driverRequest) drivers.value = result.records }
|
||||
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
|
||||
finally { if (request === driverRequest) driverLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
|
||||
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
|
||||
if (current === generation) { emit('saved', result); ElMessage.success('车辆和司机已分配,请重新核对装载') }
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer) })
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<LoadErrorState v-if="error" title="分配未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
|
||||
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
|
||||
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
|
||||
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
|
||||
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.assignment-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.assignment-form :deep(.el-select){width:100%}</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
|
||||
const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
|
||||
const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
|
||||
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
|
||||
pallets: [{ warehousePalletId: '902', palletNo: 'PLT-1', packages: 22, weightKg: 150, volumeCbm: 1.5 }] } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||||
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
|
||||
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
|
||||
LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 1 }] }))
|
||||
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
|
||||
const wrapper = mount('receipt'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled(); expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
|
||||
})
|
||||
it('retries the same incoming registration after an uncertain result without creating a new command', async () => {
|
||||
vi.mocked(api.startTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking, replayed: true })
|
||||
const wrapper = mount('incoming'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.startTripIncoming).mock.calls[0]![2]
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTripIncoming, startTripIncoming, acceptTripIncoming, bindTripBookingDispatch } from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: FleetTrip; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
|
||||
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
|
||||
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
|
||||
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
|
||||
let generation = 0
|
||||
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
|
||||
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '关联外接排车单', view: '外接来货详情' }
|
||||
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
|
||||
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
|
||||
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
|
||||
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])])
|
||||
async function load() {
|
||||
if (!props.booking) return
|
||||
const current = generation; loading.value = true; error.value = null
|
||||
try {
|
||||
const value = await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
|
||||
if (current !== generation) return
|
||||
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [open.value, target.value], () => {
|
||||
generation++; busy.value = false; error.value = null; preview.value = null
|
||||
if (!open.value || !props.booking) return
|
||||
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
|
||||
Object.assign(form, { estimatedArrivalAt: new Date(new Date(props.trip.plannedDepartureAt).getTime() - 7200000).toISOString(), deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
if (pending.value?.target !== target.value) pending.value = null
|
||||
if (props.mode === 'receipt' || props.mode === 'view') void load()
|
||||
}, { immediate: true })
|
||||
async function save() {
|
||||
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
|
||||
const current = generation, booking = props.booking, mode = props.mode
|
||||
if (!pending.value) {
|
||||
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
|
||||
if (mode === 'incoming') pending.value = { target: target.value, body: { ...versions, ...form, estimatedArrivalAt: form.estimatedArrivalAt || null } }
|
||||
else if (mode === 'bind') {
|
||||
if (!dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
|
||||
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
|
||||
} else {
|
||||
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
|
||||
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
|
||||
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]! })) } }
|
||||
}
|
||||
}
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const body = pending.value.body
|
||||
const value = mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
|
||||
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
|
||||
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
|
||||
if (current === generation) { emit('saved', value); pending.value = null; open.value = false; ElMessage.success('外接履约记录已更新') }
|
||||
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '关联排车单' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="booking" v-loading="loading">
|
||||
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
|
||||
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
|
||||
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
|
||||
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
|
||||
</el-form>
|
||||
<template v-if="mode === 'receipt' || mode === 'view'">
|
||||
<p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
|
||||
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
|
||||
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
|
||||
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
|
||||
</template>
|
||||
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingPanel from './TripBookingPanel.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking } from '../bookingTypes'
|
||||
import type { Component } from 'vue'
|
||||
const access = vi.hoisted(() => ({ writable: true, quote: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission === 'platform:fleet:quote:list' ? access.quote : access.writable }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
|
||||
vi.mock('../api/tripBookings', () => ({ listTripBookingQuoteCandidates: vi.fn(), listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() }))
|
||||
const row: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
|
||||
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount(realNumber?: Component, realInput?: Component) {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||||
const wrapper = shallowMount(TripBookingPanel, { props: { trip }, global: { stubs: {
|
||||
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.bookingNo"><slot name="actions" :row="row"/></div></div>' },
|
||||
AppDialog: { name: 'AppDialog', props: { modelValue: Boolean, showConfirm: Boolean, destroyOnClose: Boolean }, emits: ['confirm', 'update:modelValue'], template: realNumber ? '<div v-if="modelValue || !destroyOnClose" v-show="modelValue"><slot/></div>' : '<div v-if="modelValue"><slot/></div>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: realNumber || { ...field, name: 'ElInputNumber' }, ElInput: realInput || field, ElOption: true,
|
||||
LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.writable = true; access.quote = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('shows pool inquiries read-only without legacy edit, confirm or cancel actions', async () => {
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
|
||||
const wrapper = mount(); await flushPromises()
|
||||
for (const label of ['编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(node => node.text() === label)).toBe(false)
|
||||
await button(wrapper, '详情').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('请从拼车需求池管理询价与承运')
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false)
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.confirmTripBooking).not.toHaveBeenCalled()
|
||||
})
|
||||
it('confirms using both current versions and updates the parent version without extra list reads', async () => {
|
||||
vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises()
|
||||
expect(api.confirmTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 4, recordVersion: 0, idempotencyKey: expect.any(String) }))
|
||||
expect(wrapper.emitted('changed')?.[0]).toEqual([5]); expect(api.listTripBookings).toHaveBeenCalledOnce()
|
||||
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
|
||||
})
|
||||
it('reopens saved negotiated price in a fresh numeric control while retaining its separate quote source', async () => {
|
||||
const { ElInputNumber, ElInput } = await vi.importActual<typeof import('element-plus')>('element-plus')
|
||||
const initial = { ...row, cargo: { ...row.cargo, unitPrice: 16 }, quoteSource: { ...source, quotedUnitPrice: 16 } }
|
||||
const saved = { ...initial, cargo: { ...initial.cargo, unitPrice: 9 }, agreedAmount: 18, recordVersion: 1 }
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [initial] })
|
||||
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: saved, replayed: false })
|
||||
const wrapper = mount(ElInputNumber, ElInput); await flushPromises()
|
||||
await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
const price = wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!
|
||||
await price.find('input').setValue('9'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
|
||||
await button(wrapper, '详情').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.find<HTMLInputElement>('input').element.value).toBe('9.00')
|
||||
expect(wrapper.text()).toContain('来源单价:16.00 USD')
|
||||
expect(wrapper.text()).toContain('约定运费:18.00 USD')
|
||||
})
|
||||
it('keeps inquiry estimates and contacts optional and retries the same uncertain create command', async () => {
|
||||
vi.mocked(api.createTripBooking).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking: row, replayed: true })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '登记外接询价').trigger('click'); await flushPromises()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', '9'); selects[1]!.vm.$emit('update:modelValue', 'FBA:ONT8'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.createTripBooking).mock.calls[0]![1]
|
||||
expect(first.cargo).toMatchObject({ palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: null, contactPhone: null })
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createTripBooking).toHaveBeenNthCalledWith(2, 'TR-1', first)
|
||||
})
|
||||
it('does not expose booking writes to a read-only operator', async () => {
|
||||
access.writable = false; const wrapper = mount(); await flushPromises()
|
||||
for (const label of ['登记外接询价', '编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(button => button.text() === label)).toBe(false)
|
||||
})
|
||||
|
||||
it('opens reactive booking data for editing and read-only details without losing the original row', async () => {
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
|
||||
await button(wrapper, '详情').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.createTripBooking).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('can edit an inquiry after closing its read-only details', async () => {
|
||||
const wrapper = mount(); await flushPromises()
|
||||
await button(wrapper, '详情').trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' })
|
||||
expect(dialog.props('showConfirm')).toBe(false)
|
||||
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
||||
expect(button(wrapper, '编辑').attributes('disabled')).toBeUndefined()
|
||||
await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
expect(dialog.props('showConfirm')).toBe(true)
|
||||
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, recordVersion: 1 }, replayed: false })
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', {
|
||||
tripRecordVersion: 4, recordVersion: 0, cargo: row.cargo, quoteRef: null,
|
||||
})
|
||||
expect(api.listTripBookings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
const source = { quoteNo: 'FQ-ZERO', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY' as const, customerId: '9', customerName: '客户甲', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 30, currency: 'USD' as const, priceBasis: 'PALLET' as const, quotedUnitPrice: 0, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-03' }
|
||||
const candidatePage = { records: [source], page: 1, size: 20, total: 1, pages: 1 }
|
||||
it('queries only on request, keeps the exact source version and saves independently negotiated price', async () => {
|
||||
vi.mocked(api.listTripBookingQuoteCandidates).mockResolvedValue(candidatePage)
|
||||
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: row, replayed: false })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
expect(api.listTripBookingQuoteCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '查询报价库').trigger('click'); await flushPromises()
|
||||
expect(api.listTripBookingQuoteCandidates).toHaveBeenCalledExactlyOnceWith('TR-1', { customerId: '9', destinationKey: 'FBA:ONT8', currency: 'USD', priceBasis: 'PALLET', page: 1, size: 20 })
|
||||
wrapper.findAllComponents({ name: 'ElSelect' }).find(component => component.attributes('data-testid') === 'booking-quote-candidate')!.vm.$emit('update:modelValue', 'FQ-ZERO:9007199254740993:LOOSE_DELIVERY'); await flushPromises()
|
||||
const price = wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!
|
||||
expect(price.props('modelValue')).toBe(0)
|
||||
price.vm.$emit('update:modelValue', 12.34); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ quoteRef: { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }, cargo: expect.objectContaining({ unitPrice: 12.34 }) }))
|
||||
})
|
||||
it('ignores an old candidate response after changing destination and allows manual pricing', async () => {
|
||||
let resolve!: (page: typeof candidatePage) => void
|
||||
vi.mocked(api.listTripBookingQuoteCandidates).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '查询报价库').trigger('click')
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'PRIVATE:ADDRESS'); await flushPromises()
|
||||
resolve(candidatePage); await flushPromises()
|
||||
expect(wrapper.find('[data-testid="booking-quote-candidate"]').exists()).toBe(false)
|
||||
expect(button(wrapper, '查询报价库').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.props('modelValue')).toBe(50)
|
||||
})
|
||||
it('clears a selected source explicitly without erasing the negotiated price', async () => {
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, quoteSource: source }] })
|
||||
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: row, replayed: false })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '改为手工询价').trigger('click')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ quoteRef: null, cargo: row.cargo }))
|
||||
})
|
||||
it('keeps manual entry available when candidate lookup fails and hides lookup without quote permission', async () => {
|
||||
vi.mocked(api.listTripBookingQuoteCandidates).mockRejectedValue(new Error('仓库未配置时区'))
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '查询报价库').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('报价库查询未完成,可继续手工询价')
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.props('modelValue')).toBe(50)
|
||||
access.quote = false
|
||||
const denied = mount(); await flushPromises(); await button(denied, '编辑').trigger('click'); await flushPromises()
|
||||
expect(denied.findAll('button').some(node => node.text() === '查询报价库')).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking, listTripBookingQuoteCandidates } from '../api/tripBookings'
|
||||
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
import { bookingStatusLabels, type TripBooking, type TripBookingCargo, type TripBookingCreate, type TripBookingCommand, type TripBookingResult, type FleetCustomerOption } from '../bookingTypes'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
const props = defineProps<{ trip: FleetTrip }>()
|
||||
const emit = defineEmits<{ changed: [version: number]; linked: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const records = ref<TripBooking[]>([]), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const fulfillmentOpen = ref(false), fulfillmentBooking = ref<TripBooking | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') { fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true }
|
||||
function fulfilled(value: TripBookingResult) { accept(value); if (fulfillmentMode.value === 'bind') { emit('linked'); void load() } }
|
||||
const readOnly = ref(false)
|
||||
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
|
||||
const pendingCreate = ref<TripBookingCreate | null>(null), pendingCommand = ref<{ bookingNo: string; action: 'confirm' | 'cancel'; body: TripBookingCommand } | null>(null)
|
||||
const version = ref<number | null>(null), customerId = ref('')
|
||||
const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
const form = reactive<TripBookingCargo>(blank())
|
||||
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), quoteRef = ref<FleetQuoteReference | null>(null)
|
||||
const candidates = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null)
|
||||
const quotePage = ref(1), quotePages = ref(0), quoteSearched = ref(false)
|
||||
let quoteSequence = 0
|
||||
const canReadQuotes = computed(() => access.can(permission.fleetQuoteList))
|
||||
const quoteLocked = computed(() => readOnly.value || busy.value || !!pendingCreate.value || !canReadQuotes.value)
|
||||
const canQueryQuotes = computed(() => !!customerId.value && form.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.currency || ''))
|
||||
const sourceKey = (value: FleetQuoteSourceSnapshot | FleetQuoteReference) => `${value.quoteNo}:${value.recordVersion}:${value.priceItem}`
|
||||
function resetCandidates() { quoteSequence++; candidates.value = []; quoteError.value = null; quoteLoading.value = false; quoteSearched.value = false; quotePage.value = 1; quotePages.value = 0 }
|
||||
function clearQuote() { if (quoteLocked.value) return; quoteRef.value = null; quoteSource.value = null }
|
||||
function selectQuote(key: string) {
|
||||
if (quoteLocked.value) return
|
||||
const source = candidates.value.find(row => sourceKey(row) === key)
|
||||
if (!source) return
|
||||
quoteSource.value = source; quoteRef.value = fleetQuoteReference(source); form.unitPrice = source.quotedUnitPrice
|
||||
}
|
||||
async function queryQuotes(page = 1) {
|
||||
if (quoteLocked.value || !canQueryQuotes.value) return
|
||||
const current = ++quoteSequence; quoteLoading.value = true; quoteError.value = null
|
||||
try {
|
||||
const result = await listTripBookingQuoteCandidates(props.trip.tripNo, { customerId: customerId.value, destinationKey: form.destinationKey, currency: form.currency as 'CNY' | 'USD', priceBasis: form.priceBasis, page, size: 20 })
|
||||
if (current === quoteSequence) { candidates.value = result.records; quotePage.value = result.page; quotePages.value = result.pages; quoteSearched.value = true }
|
||||
} catch (cause) { if (current === quoteSequence) { candidates.value = []; quoteError.value = getApiErrorDetails(cause, '报价库查询未完成,可继续手工询价') } }
|
||||
finally { if (current === quoteSequence) quoteLoading.value = false }
|
||||
}
|
||||
watch(() => [customerId.value, form.destinationKey, form.currency, form.priceBasis], () => { resetCandidates(); quoteSource.value = null; quoteRef.value = null }, { flush: 'sync' })
|
||||
let generation = 0, searchSequence = 0
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const planned = computed(() => props.trip.status === 'PLANNED')
|
||||
const quotedAmount = computed(() => form.unitPrice == null ? null : form.priceBasis === 'ORDER' ? form.unitPrice : form.palletSpaces == null ? null : Math.round(form.unitPrice * form.palletSpaces * 100) / 100)
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: planned.value ? 320 : 170, fixed: 'left' },
|
||||
{ key: 'bookingNo', prop: 'bookingNo', label: '外接预订号', minWidth: 225 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '委托客户', minWidth: 170 },
|
||||
{ key: 'destinationLabel', prop: 'destinationLabel', label: '卸货站', minWidth: 140 },
|
||||
{ key: 'status', label: '状态', slotName: 'status', width: 130 },
|
||||
{ key: 'cargo', label: '承运货量', slotName: 'cargo', minWidth: 225 },
|
||||
{ key: 'amount', label: '约定运费', slotName: 'amount', minWidth: 160 },
|
||||
{ key: 'documents', label: '来货 / 排车', slotName: 'documents', minWidth: 230 },
|
||||
])
|
||||
async function load() {
|
||||
const current = ++generation; loading.value = true; error.value = null
|
||||
try { const value = await listTripBookings(props.trip.tripNo); if (current === generation) { records.value = value.records; version.value = value.tripRecordVersion
|
||||
if (pendingCommand.value) { const row = value.records.find(row => row.bookingNo === pendingCommand.value!.bookingNo); if (row && (row.status === 'CANCELLED' || pendingCommand.value.action === 'confirm' && row.status === 'CONFIRMED')) pendingCommand.value = null } } }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '外接预订加载失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function searchCustomers(keyword = '') {
|
||||
clearTimeout(searchTimer); const current = ++searchSequence
|
||||
searchTimer = setTimeout(async () => {
|
||||
customersLoading.value = true
|
||||
try { const rows = await listFleetCustomers(keyword.trim() || undefined); if (current === searchSequence) customers.value = rows }
|
||||
catch (cause) { if (current === searchSequence) error.value = getApiErrorDetails(cause, '客户目录加载失败') }
|
||||
finally { if (current === searchSequence) customersLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
function edit(row?: TripBooking, viewOnly = false) {
|
||||
readOnly.value = viewOnly || !!row?.offerNo
|
||||
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
|
||||
editing.value = row || null
|
||||
resetCandidates()
|
||||
if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo); quoteRef.value = pendingCreate.value.quoteRef || null }
|
||||
else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()); quoteSource.value = row?.quoteSource || null; quoteRef.value = quoteSource.value ? fleetQuoteReference(quoteSource.value) : null }
|
||||
open.value = true
|
||||
}
|
||||
function accept(value: TripBookingResult) {
|
||||
version.value = value.tripRecordVersion
|
||||
const index = records.value.findIndex(row => row.bookingNo === value.booking.bookingNo)
|
||||
if (index < 0) records.value.push(value.booking); else records.value[index] = value.booking
|
||||
emit('changed', value.tripRecordVersion)
|
||||
}
|
||||
function failure(cause: unknown, fallback: string) {
|
||||
error.value = getApiErrorDetails(cause, fallback)
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pendingCreate.value = null; pendingCommand.value = null }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || readOnly.value || editing.value?.offerNo || version.value == null) return
|
||||
if (editing.value?.quoteSource && !canReadQuotes.value) { ElMessage.warning('编辑带报价来源的询价需要报价读取权限'); return }
|
||||
if (!customerId.value || !form.destinationKey) { ElMessage.warning('请选择客户和卸货站'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
let value: TripBookingResult
|
||||
if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }), quoteRef: quoteRef.value ? { ...quoteRef.value } : null })
|
||||
else {
|
||||
pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), quoteRef: quoteRef.value ? { ...quoteRef.value } : null, idempotencyKey: crypto.randomUUID() }
|
||||
value = await createTripBooking(props.trip.tripNo, pendingCreate.value)
|
||||
}
|
||||
if (current === generation) { accept(value); pendingCreate.value = null; open.value = false; ElMessage.success('外接询价已保存') }
|
||||
} catch (cause) { if (current === generation) failure(cause, '外接登记未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function command(row: TripBooking, action: 'confirm' | 'cancel') {
|
||||
if (busy.value || row.offerNo || version.value == null) return
|
||||
if (pendingCommand.value && (pendingCommand.value.bookingNo !== row.bookingNo || pendingCommand.value.action !== action)) { ElMessage.warning('请先重试上一条操作或刷新核对'); return }
|
||||
const current = generation
|
||||
if (!pendingCommand.value) {
|
||||
try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量预留本车容量?` : `取消 ${row.bookingNo},释放尚未执行的预订容量?已到仓货物继续保留在仓库。`, action === 'confirm' ? '确认外接承运' : '取消外接预订', { customClass: 'admin-density-confirm' }) }
|
||||
catch { return }
|
||||
if (current !== generation) return
|
||||
pendingCommand.value = { bookingNo: row.bookingNo, action, body: { tripRecordVersion: version.value, recordVersion: row.recordVersion, idempotencyKey: crypto.randomUUID() } }
|
||||
}
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const value = await (action === 'confirm' ? confirmTripBooking : cancelTripBooking)(props.trip.tripNo, row.bookingNo, pendingCommand.value.body)
|
||||
if (current === generation) { accept(value); pendingCommand.value = null; ElMessage.success('外接预订状态已更新') }
|
||||
} catch (cause) { if (current === generation) failure(cause, '外接预订操作未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true })
|
||||
watch(open, value => { if (!value) resetCandidates() })
|
||||
watch(() => [props.trip.tripNo, props.trip.recordVersion], resetCandidates)
|
||||
onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTimeout(searchTimer) })
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<p>询价不占用车辆空间;确认承运后预留容量。实收时核对运费,转派送时按确认报价登记应收,付款状态以财务为准。</p>
|
||||
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
|
||||
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
|
||||
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" destroy-on-close :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
|
||||
<p v-if="editing?.offerNo">池来源意向 {{ editing.offerNo }},请从拼车需求池管理询价与承运。</p><el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
|
||||
<el-form-item label="委托客户" required><el-select v-model="customerId" filterable remote :remote-method="searchCustomers" :loading="customersLoading" :disabled="!!editing" placeholder="按客户名称、编号搜索" @focus="searchCustomers()"><el-option v-if="editing" :value="editing.customerId" :label="editing.customerName" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.destinationKey" placeholder="选择顺路卸货站"><el-option v-for="stop in trip.stops.slice(1).filter(row => row.destinationKey)" :key="stop.destinationKey!" :value="stop.destinationKey!" :label="stop.name" /></el-select></el-form-item>
|
||||
<el-form-item label="货物名称"><el-input v-model="form.cargoName" placeholder="填写货物名称(选填)" /></el-form-item>
|
||||
<el-form-item label="预计箱数"><el-input-number v-model="form.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="预计标准托位"><el-input-number v-model="form.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="报价方式"><el-select v-model="form.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
|
||||
<el-form-item :label="form.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.unitPrice" :min="0" :precision="2" placeholder="可先保存询价" /></el-form-item>
|
||||
<el-form-item label="币种"><el-select v-model="form.currency" filterable allow-create placeholder="选择或输入币种"><el-option v-for="currency in ['USD','CAD','EUR','GBP','CNY']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="form.contactName" placeholder="填写联系人(选填)" /></el-form-item>
|
||||
<el-form-item label="联系方式"><el-input v-model="form.contactPhone" placeholder="填写电话或其他联系方式(选填)" /></el-form-item>
|
||||
<el-form-item label="备注" class="booking-wide"><el-input v-model="form.remark" type="textarea" placeholder="其他交货说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<section class="booking-quote" aria-label="报价来源">
|
||||
<p v-if="quoteSource">来源:{{ quoteSource.quoteNo }} · 版本 {{ quoteSource.recordVersion }} · {{ quoteSource.originCity }} → {{ quoteSource.destinationCode }} · 适用日期 {{ quoteSource.effectiveOn }}<br>来源单价:{{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }} / {{ quoteSource.priceBasis === 'PALLET' ? '托位' : '单' }} · 有效期 {{ quoteSource.validFrom }} 至 {{ quoteSource.validTo }}</p>
|
||||
<p v-else>{{ quoteRef ? `已选报价 ${quoteRef.quoteNo} · 版本 ${quoteRef.recordVersion}` : '手工询价,尚未选择报价库来源。' }}</p>
|
||||
<template v-if="!readOnly && canReadQuotes">
|
||||
<el-button :disabled="quoteLocked || !canQueryQuotes || quoteLoading" @click="queryQuotes()">查询报价库</el-button>
|
||||
<el-button v-if="quoteRef" :disabled="quoteLocked" @click="clearQuote">改为手工询价</el-button>
|
||||
<p v-if="!canQueryQuotes">选择客户、FBA卸货站及CNY或USD后可查询;其他场景可继续填写手工价格。</p>
|
||||
<el-select v-if="candidates.length" data-testid="booking-quote-candidate" :model-value="quoteRef ? sourceKey(quoteRef) : ''" :disabled="quoteLocked || quoteLoading" placeholder="选择报价后带入单价,可另行议价" @update:model-value="selectQuote">
|
||||
<el-option v-for="item in candidates" :key="sourceKey(item)" :value="sourceKey(item)" :label="`${item.quoteNo} · ${item.quotedUnitPrice.toFixed(2)} ${item.currency} · ${item.validFrom} 至 ${item.validTo}`" />
|
||||
</el-select>
|
||||
<p v-if="quoteSearched && !candidates.length">暂无符合本车路线、客户、日期和币种的报价,可继续手工询价。</p>
|
||||
<div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
|
||||
<p v-if="quoteError" role="alert">{{ quoteError.message }} <ApiErrorMeta :details="quoteError" /></p>
|
||||
</template>
|
||||
<p v-if="quoteRef">来源报价与实际约定价格分别保存;确认承运时会重新核对报价版本与适用日期。</p>
|
||||
</section>
|
||||
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
|
||||
</AppDialog>
|
||||
<TripBookingFulfillmentDialog v-model="fulfillmentOpen" :trip="trip" :booking="fulfillmentBooking" :mode="fulfillmentMode" @saved="fulfilled" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.booking-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.booking-form :deep(.el-select),.booking-form :deep(.el-input-number){width:100%}.booking-wide{grid-column:1/-1}</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripCostPanel from './TripCostPanel.vue'
|
||||
import * as api from '../api/tripCosts'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const access = vi.hoisted(() => ({ read: true, write: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') ? access.read : access.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/tripCosts', () => ({ listTripCosts: vi.fn(), createTripCost: vi.fn(), updateTripCost: vi.fn(), listTripCostHistory: vi.fn() }))
|
||||
const cost: api.TripCost = { costNo: 'TC-ONE', tripNo: 'TR-ONE', details: { category: 'FUEL', currency: 'USD', amount: 10, incurredAt: '2026-01-01T00:00:00Z', remark: null }, recordVersion: '1', updatedAt: '2026-01-01T00:00:00Z' }
|
||||
const result: api.TripCosts = { page: { records: [cost], page: 1, size: 10, total: 1, pages: 1 }, summary: { departureVersion: null, manifestHash: null, allocationMethod: null, allocationBlockReason: '尚无实际发车装载记录', totals: [] } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
|
||||
function mount() {
|
||||
const wrapper = shallowMount(TripCostPanel, { props: { tripNo: 'TR-ONE' }, global: { stubs: {
|
||||
ElButton: { template: '<button><slot/></button>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
|
||||
ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input,
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.costNo"><slot name="actions" :row="row"/></div></div>' },
|
||||
RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '<div><button v-for="a in actions.filter(a => a.visible !== false)" :key="a.key" @click="$emit(\'action\', a.key)">{{ a.label }}</button></div>' },
|
||||
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
function button(w: VueWrapper, label: string) { return w.findAll('button').find(b => b.text() === label)! }
|
||||
beforeEach(() => { vi.resetAllMocks(); access.read = true; access.write = true; vi.mocked(api.listTripCosts).mockResolvedValue(structuredClone(result)); vi.mocked(api.createTripCost).mockResolvedValue(cost); vi.mocked(api.updateTripCost).mockResolvedValue(cost)
|
||||
vi.mocked(api.listTripCostHistory).mockResolvedValue({ records: [{ recordVersion: '9', action: 'UPDATE', reason: '更正', occurredAt: cost.updatedAt, snapshot: { cost: { ...cost, recordVersion: '9' }, summary: result.summary } }], page: 1, size: 1, total: 1, pages: 1 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it('loads one scoped page and summary without per-row reads or invented zero totals', async () => {
|
||||
const w = mount(); await flushPromises()
|
||||
expect(api.listTripCosts).toHaveBeenCalledExactlyOnceWith('TR-ONE', 1, 10)
|
||||
expect(api.listTripCostHistory).not.toHaveBeenCalled(); expect(w.text()).toContain('尚未登记直接成本')
|
||||
})
|
||||
it('does not request cost data without list permission, and hides writes for read-only users', async () => {
|
||||
access.read = false; const denied = mount(); await flushPromises(); expect(api.listTripCosts).not.toHaveBeenCalled(); expect(denied.text()).toContain('暂无车次成本查看权限')
|
||||
access.read = true; access.write = false; const readOnly = mount(); await flushPromises()
|
||||
expect(readOnly.text()).not.toContain('登记成本'); expect(readOnly.text()).not.toContain('更正'); expect(readOnly.text()).toContain('历史')
|
||||
})
|
||||
it('ignores late responses after switching trips and resets later pagination once', async () => {
|
||||
const w = mount(); await flushPromises(); w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 3); await flushPromises()
|
||||
let finish!: (value: api.TripCosts) => void
|
||||
vi.mocked(api.listTripCosts).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises()
|
||||
vi.mocked(api.listTripCosts).mockResolvedValueOnce({ ...result, page: { ...result.page, records: [{ ...cost, costNo: 'TC-THREE', tripNo: 'TR-THREE' }] } })
|
||||
await w.setProps({ tripNo: 'TR-THREE' }); await flushPromises(); finish(result); await flushPromises()
|
||||
expect(api.listTripCosts).toHaveBeenNthCalledWith(3, 'TR-TWO', 1, 10)
|
||||
expect(api.listTripCosts).toHaveBeenNthCalledWith(4, 'TR-THREE', 1, 10)
|
||||
expect(w.findComponent({ name: 'DataTable' }).props('data')[0].costNo).toBe('TC-THREE')
|
||||
})
|
||||
it('retains the exact key and contents when a creation response is uncertain', async () => {
|
||||
vi.mocked(api.createTripCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce(cost)
|
||||
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5); await button(w, '保存').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.createTripCost).mock.calls[0]!
|
||||
expect(w.text()).toContain('正在核对上次登记结果')
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual(first)
|
||||
})
|
||||
it('keeps the successful save authoritative when pagination changes while it is pending', async () => {
|
||||
let finish!: (value: api.TripCost) => void
|
||||
vi.mocked(api.createTripCost).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
finish(cost); await flushPromises()
|
||||
expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false)
|
||||
expect(w.text()).not.toContain('正在核对上次登记结果')
|
||||
expect(api.listTripCosts).toHaveBeenLastCalledWith('TR-ONE', 1, 10)
|
||||
expect(api.createTripCost).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('retains the original request across trip switches and ignores the first response during its retry', async () => {
|
||||
let finishFirst!: (value: api.TripCost) => void
|
||||
let finishSecond!: (value: api.TripCost) => void
|
||||
vi.mocked(api.createTripCost)
|
||||
.mockImplementationOnce(() => new Promise(resolve => { finishFirst = resolve }))
|
||||
.mockImplementationOnce(() => new Promise(resolve => { finishSecond = resolve }))
|
||||
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises()
|
||||
await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false)
|
||||
await w.setProps({ tripNo: 'TR-ONE' }); await flushPromises()
|
||||
await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
finishFirst(cost); await flushPromises()
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
expect(api.createTripCost).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual(vi.mocked(api.createTripCost).mock.calls[0])
|
||||
finishSecond(cost); await flushPromises()
|
||||
expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false)
|
||||
})
|
||||
it('unlocks a definite HTTP200 business rejection for correction with a fresh create key', async () => {
|
||||
vi.mocked(api.createTripCost).mockRejectedValueOnce(new ApiResponseError('业务拒绝', 'A0422', 200))
|
||||
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.createTripCost).mock.calls[0]!
|
||||
expect(w.text()).not.toContain('正在核对上次登记结果')
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
const second = vi.mocked(api.createTripCost).mock.calls[1]!
|
||||
expect(second[1]).not.toBe(first[1]); expect(second[2].amount).toBe(10)
|
||||
})
|
||||
it('reads the current version before editing and avoids writing unchanged contents', async () => {
|
||||
const w = mount(); await flushPromises(); await button(w, '更正').trigger('click'); await flushPromises()
|
||||
expect(api.listTripCostHistory).toHaveBeenCalledExactlyOnceWith('TR-ONE', 'TC-ONE', 1, 1)
|
||||
await button(w, '保存').trigger('click'); await flushPromises(); expect(api.updateTripCost).not.toHaveBeenCalled()
|
||||
await button(w, '更正').trigger('click'); await flushPromises(); w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
expect(api.updateTripCost).toHaveBeenCalledWith('TR-ONE', expect.objectContaining({ recordVersion: '9' }), expect.objectContaining({ amount: 15 }), '')
|
||||
})
|
||||
it('failed refresh clears both rows and summary instead of keeping stale evidence', async () => {
|
||||
const w = mount(); await flushPromises(); vi.mocked(api.listTripCosts).mockRejectedValueOnce(new Error('查询失败'))
|
||||
await button(w, '刷新').trigger('click'); await flushPromises()
|
||||
expect(w.findComponent({ name: 'DataTable' }).props('data')).toEqual([]); expect(w.text()).toContain('车次成本加载失败'); expect(w.text()).not.toContain('尚无实际发车装载记录')
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { listTripCosts, createTripCost, updateTripCost, listTripCostHistory,
|
||||
type CostCategory, type CostCurrency, type TripCost, type TripCostDraft, type CostSummary, type TripCostHistory } from '../api/tripCosts'
|
||||
|
||||
const props = defineProps<{ tripNo: string }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const allowed = computed(() => access.can(permission.tripList))
|
||||
const rows = ref<TripCost[]>([]), summary = ref<CostSummary | null>(null), page = ref(1), size = ref(10), total = ref(0), loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false), editing = ref<TripCost | null>(null), opening = ref(false), saving = ref(false), reason = ref('')
|
||||
const form = reactive<{ category: CostCategory; currency: CostCurrency; amount: number | null; incurredAt: string; remark: string }>({ category: 'FUEL', currency: 'USD', amount: null, incurredAt: '', remark: '' })
|
||||
const pendingCreate = ref<{ tripNo: string; key: string; details: TripCostDraft } | null>(null)
|
||||
const writable = computed(() => allowed.value && access.can(editing.value ? permission.tripCostEdit : permission.tripCostCreate))
|
||||
const historyOpen = ref(false), historyNo = ref(''), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
|
||||
const histories = ref<TripCostHistory[]>([]), historyError = ref<ApiErrorDetails | null>(null)
|
||||
const categories: Record<CostCategory, string> = { FUEL: '燃油', DRIVER: '司机费用', TOLL: '通行费', PARKING: '停车费', OTHER: '其他直接费用' }
|
||||
let generation = 0, historyGeneration = 0, scopeGeneration = 0, lastSaved = ''
|
||||
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
const money = (value: number | null) => value == null ? '暂未分摊' : value.toFixed(2)
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'costNo', prop: 'costNo', label: '成本编号', minWidth: 220 },
|
||||
{ key: 'category', label: '费用类别', width: 140, slotName: 'category' },
|
||||
{ key: 'amount', label: '登记金额', width: 160, slotName: 'amount' },
|
||||
{ key: 'incurredAt', label: '发生时间', minWidth: 180, slotName: 'time' },
|
||||
{ key: 'remark', label: '备注', minWidth: 260, slotName: 'remark' },
|
||||
]
|
||||
const allocationColumns: DataTableColumn[] = [
|
||||
{ key: 'sourceType', label: '货物来源', width: 120, slotName: 'source' },
|
||||
{ key: 'businessNo', prop: 'businessNo', label: '业务编号', minWidth: 220 },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车', minWidth: 220 },
|
||||
{ key: 'palletSpaces', prop: 'palletSpaces', label: '实际标准托位', minWidth: 140 },
|
||||
{ key: 'amount', label: '分摊金额', minWidth: 160, slotName: 'amount' },
|
||||
]
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'time' },
|
||||
{ key: 'action', label: '操作', width: 90, slotName: 'action' },
|
||||
{ key: 'recordVersion', prop: 'recordVersion', label: '版本', width: 80 },
|
||||
{ key: 'amount', label: '当时金额', minWidth: 150, slotName: 'amount' },
|
||||
{ key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
|
||||
]
|
||||
function draft(): TripCostDraft { return { category: form.category, currency: form.currency, amount: form.amount!, incurredAt: form.incurredAt, remark: form.remark.trim() || null } }
|
||||
function actions(): RowActionItem[] { return [
|
||||
{ key: 'edit', label: '更正', visible: access.can(permission.tripCostEdit), disabled: opening.value || saving.value },
|
||||
{ key: 'history', label: '历史', disabled: opening.value || saving.value },
|
||||
] }
|
||||
async function load() {
|
||||
const request = ++generation
|
||||
rows.value = []; total.value = 0; summary.value = null; error.value = null
|
||||
if (!allowed.value) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listTripCosts(props.tripNo, page.value, size.value)
|
||||
if (request === generation) { rows.value = result.page.records; total.value = result.page.total; summary.value = result.summary }
|
||||
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '车次成本加载失败') }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
watch([() => props.tripNo, page, size, allowed], (current, previous) => {
|
||||
if (previous?.length && current[0] !== previous[0]) { scopeGeneration++; generation++; historyGeneration++; editorOpen.value = false; historyOpen.value = false; opening.value = false; saving.value = false }
|
||||
if (!current[3]) { scopeGeneration++; editorOpen.value = false; historyOpen.value = false; histories.value = []; historyGeneration++; opening.value = false; saving.value = false }
|
||||
if (previous?.length && (current[0] !== previous[0] || current[2] !== previous[2]) && page.value !== 1) { page.value = 1; return }
|
||||
void load()
|
||||
}, { immediate: true })
|
||||
async function beginEdit(row: TripCost | null) {
|
||||
if (opening.value || saving.value) return
|
||||
if (pendingCreate.value && pendingCreate.value.tripNo !== props.tripNo) { ElMessage.warning(`请先返回车次 ${pendingCreate.value.tripNo} 核对上次登记结果`); return }
|
||||
if (row && pendingCreate.value) { ElMessage.warning('请先重新打开登记窗口,核对上次保存结果'); return }
|
||||
if (!allowed.value || !access.can(row ? permission.tripCostEdit : permission.tripCostCreate)) return
|
||||
const scope = props.tripNo, request = scopeGeneration
|
||||
opening.value = true; saveError.value = null
|
||||
try {
|
||||
let current = row
|
||||
if (row) {
|
||||
const result = await listTripCostHistory(scope, row.costNo, 1, 1)
|
||||
current = result.records[0]?.snapshot.cost ?? null
|
||||
if (!current) throw new Error('成本记录最新版本不存在,请刷新列表')
|
||||
}
|
||||
if (request !== scopeGeneration || scope !== props.tripNo) return
|
||||
editing.value = current; reason.value = ''
|
||||
if (pendingCreate.value) Object.assign(form, pendingCreate.value.details, { remark: pendingCreate.value.details.remark ?? '' })
|
||||
else Object.assign(form, current ? { ...current.details, remark: current.details.remark ?? '' }
|
||||
: { category: 'FUEL', currency: 'USD', amount: null, incurredAt: new Date().toISOString(), remark: '' })
|
||||
lastSaved = current ? JSON.stringify(draft()) : ''
|
||||
editorOpen.value = true
|
||||
} catch (cause) { if (scope === props.tripNo && request === scopeGeneration) error.value = getApiErrorDetails(cause, '成本详情加载失败') }
|
||||
finally { if (scope === props.tripNo && request === scopeGeneration) opening.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value || !writable.value) return
|
||||
if (pendingCreate.value && pendingCreate.value.tripNo !== props.tripNo) return
|
||||
if (form.amount == null || !Number.isFinite(form.amount) || form.amount < 0 || !form.incurredAt) { ElMessage.warning('请填写金额和实际发生时间'); return }
|
||||
const details = pendingCreate.value?.details ?? draft()
|
||||
if (editing.value && JSON.stringify(details) === lastSaved) { editorOpen.value = false; return }
|
||||
const scope = props.tripNo, request = scopeGeneration
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
if (editing.value) await updateTripCost(scope, editing.value, details, reason.value)
|
||||
else {
|
||||
pendingCreate.value ??= { tripNo: scope, key: crypto.randomUUID(), details }
|
||||
await createTripCost(scope, pendingCreate.value.key, pendingCreate.value.details)
|
||||
}
|
||||
if (scope !== props.tripNo || request !== scopeGeneration) return
|
||||
pendingCreate.value = null; editorOpen.value = false; ElMessage.success('车次成本已保存')
|
||||
if (page.value !== 1) page.value = 1
|
||||
else await load()
|
||||
} catch (cause) {
|
||||
if (scope !== props.tripNo || request !== scopeGeneration) return
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingCreate.value = null
|
||||
saveError.value = getApiErrorDetails(cause, '成本保存失败')
|
||||
} finally { if (scope === props.tripNo && request === scopeGeneration) saving.value = false }
|
||||
}
|
||||
function showHistory(row: TripCost) { historyNo.value = row.costNo; historyPage.value = 1; historyOpen.value = true }
|
||||
async function loadHistory() {
|
||||
const request = ++historyGeneration
|
||||
histories.value = []; historyTotal.value = 0; historyError.value = null
|
||||
if (!historyOpen.value || !allowed.value) return
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const result = await listTripCostHistory(props.tripNo, historyNo.value, historyPage.value, 10)
|
||||
if (request === historyGeneration) { histories.value = result.records; historyTotal.value = result.total }
|
||||
} catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '成本历史加载失败') }
|
||||
finally { if (request === historyGeneration) historyLoading.value = false }
|
||||
}
|
||||
watch([historyOpen, historyNo, historyPage], loadHistory)
|
||||
onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="车次直接成本">
|
||||
<p v-if="!allowed">暂无车次成本查看权限。</p>
|
||||
<template v-else>
|
||||
<p class="cost-note">按实际费用登记,金额按币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p>
|
||||
<LoadErrorState v-if="error" title="车次成本加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="summary">
|
||||
<p v-if="!summary.totals.length" class="cost-note">尚未登记直接成本。</p>
|
||||
<p v-if="summary.allocationBlockReason" class="cost-note">{{ summary.allocationBlockReason }}</p>
|
||||
<div v-for="value in summary.totals" :key="value.currency" class="cost-summary">
|
||||
<strong>{{ value.currency }} · 已登记直接成本 {{ value.directCost.toFixed(2) }}</strong>
|
||||
<span>自有货分摊 {{ money(value.ownCost) }} · 外接货分摊 {{ money(value.externalCost) }}</span>
|
||||
<DataTable v-if="value.allocations.length" :data="value.allocations" :columns="allocationColumns" row-key="businessNo">
|
||||
<template #source="{ row }">{{ row.sourceType === 'EXTERNAL' ? '外接货' : '自有业务货物' }}</template>
|
||||
<template #amount="{ row }">{{ value.currency }} {{ row.amount.toFixed(2) }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="costNo" empty-text="暂无直接成本记录">
|
||||
<template #toolbar-actions><el-button :disabled="loading || saving" @click="load">刷新</el-button><el-button v-if="access.can(permission.tripCostCreate)" type="primary" :disabled="opening || saving" @click="beginEdit(null)">登记成本</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'edit' ? beginEdit(row as TripCost) : showHistory(row as TripCost)" /></template>
|
||||
<template #category="{ row }">{{ categories[row.details.category as CostCategory] }}</template>
|
||||
<template #amount="{ row }">{{ row.details.currency }} {{ row.details.amount.toFixed(2) }}</template>
|
||||
<template #time="{ row }">{{ time(row.details.incurredAt) }}</template>
|
||||
<template #remark="{ row }">{{ row.details.remark || '—' }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
<AppDialog v-model="editorOpen" :title="editing ? '更正车次直接成本' : '登记车次直接成本'" width="680px" class="admin-density-dialog" confirm-text="保存成本" :confirm-loading="saving" :confirm-disabled="!writable" @confirm="save">
|
||||
<LoadErrorState v-if="saveError" title="成本保存未完成" :message="saveError.message" @retry="save"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="pendingCreate" class="cost-note">正在核对上次登记结果,重试会使用同一请求,不重复记账。</p>
|
||||
<el-form label-position="top" :disabled="saving || !writable || !!pendingCreate" class="cost-form">
|
||||
<el-form-item label="费用类别" required><el-select v-model="form.category" placeholder="请选择费用类别"><el-option v-for="(label, key) in categories" :key="key" :value="key" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="币种" required><el-select v-model="form.currency" placeholder="请选择原始币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item label="实际金额" required><el-input-number v-model="form.amount" :min="0" :max="999999999999.99" :precision="2" placeholder="请输入金额,可明确登记为零" /></el-form-item>
|
||||
<el-form-item label="发生时间" required><el-date-picker v-model="form.incurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择实际发生时间" /></el-form-item>
|
||||
<el-form-item label="备注(选填)" class="cost-wide"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充费用用途或凭据说明" /></el-form-item>
|
||||
<el-form-item v-if="editing" label="更正说明(选填)" class="cost-wide"><el-input v-model="reason" maxlength="1000" placeholder="补充本次更正原因" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="historyOpen" :title="`${historyNo} · 成本历史`" width="1080px" class="admin-density-dialog" :show-confirm="false" cancel-text="关闭">
|
||||
<LoadErrorState v-if="historyError" title="成本历史加载失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState>
|
||||
<DataTable v-model:page="historyPage" :page-size="10" :data="histories" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="recordVersion">
|
||||
<template #time="{ row }">{{ time(row.occurredAt) }}</template>
|
||||
<template #action="{ row }">{{ row.action === 'CREATE' ? '登记' : '更正' }}</template>
|
||||
<template #amount="{ row }">{{ row.snapshot.cost.details.currency }} {{ row.snapshot.cost.details.amount.toFixed(2) }}</template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.cost-note{color:var(--cc-muted);margin:0 0 16px}.cost-summary{display:grid;gap:12px;margin:0 0 20px}.cost-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px}.cost-wide{grid-column:1/-1}.cost-form :deep(.el-select),.cost-form :deep(.el-input-number),.cost-form :deep(.el-date-editor){width:100%}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripHandoverPanel from './TripHandoverPanel.vue'
|
||||
import * as api from '../api/tripHandovers'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const access = vi.hoisted(() => ({ read: true, write: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') ? access.read : access.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/tripHandovers', async original => ({ ...(await original<typeof api>()), listTripHandovers: vi.fn(), previewTripHandover: vi.fn(), confirmTripHandover: vi.fn(), releaseHandoverResource: vi.fn() }))
|
||||
vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() }))
|
||||
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() }))
|
||||
const date = '2026-10-03T01:00:00Z'
|
||||
const detail: TripDetail = { trip: { tripNo: 'TR-ONE', status: 'IN_TRANSIT', warehouseId: '1', warehouseCode: 'WH', warehouseName: '海外仓', vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone', maxPalletSpaces: 2, maxLoadKg: 200, maxVolumeCbm: 4, plannedDepartureAt: date, expectedDriverEndAt: '2026-10-04T01:00:00Z', departedAt: date, completedAt: null, stops: [], remark: null, recordVersion: 7, updatedAt: date, vehicleRelease: null, driverRelease: null }, driverQualification: null, dispatches: [], loadingReview: { status: 'APPROVED', reason: null, reviewerId: null, reviewedAt: date, recordVersion: 7 } }
|
||||
const previous = { vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone' }
|
||||
const replacement = { vehicleId: '12', vehiclePlate: 'NEW-PLATE', driverId: '22', driverName: '接替司机', driverPhone: 'phone' }
|
||||
const load = { palletSpaces: 1, weightKg: 100, volumeCbm: 2 }
|
||||
const cargo = { remainingCargo: [{ customerId: 'secret-customer-id', dispatchNo: 'PC-A', itemId: 'secret-item-id', palletId: 'secret-pallet-id', destinationKey: 'private-hash', packages: 10 }], completedDeliveries: [], remainingPalletIds: ['secret-pallet-id'], occupied: load }
|
||||
const preview: api.HandoverPreview = { tripNo: 'TR-ONE', recordVersion: 7, cargoHash: 'a'.repeat(64), previous, replacement, capacity: load, cargo, driverQualification: { canDispatch: true, expiringSoon: true, reason: 'ELIGIBLE', licenseExpiresOn: '2026-10-15' } }
|
||||
const record: api.HandoverRecord = { handoverNo: 'HO-ONE', tripNo: 'TR-ONE', recordVersion: 0, snapshot: { previous, replacement, handedOverAt: date, location: '实际交接点', reason: '交接', expectedDriverEndAt: date, capacity: load, cargo, recordedAt: date }, vehicleRelease: null, driverRelease: null }
|
||||
const wrappers: VueWrapper[] = []
|
||||
const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
|
||||
function mount() {
|
||||
const wrapper = shallowMount(TripHandoverPanel, { props: { detail: structuredClone(detail) }, global: { stubs: {
|
||||
ElButton: { template: '<button><slot/></button>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
|
||||
ElInput: { ...input, name: 'ElInput' }, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: input,
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.handoverNo"><slot name="actions" :row="row"/></div></div>' },
|
||||
RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '<div><button v-for="a in actions" :key="a.key" @click="$emit(\'action\', a.key)">{{ a.label }}</button></div>' },
|
||||
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
const button = (w: VueWrapper, text: string) => w.findAll('button').find(row => row.text() === text)!
|
||||
beforeEach(() => { vi.clearAllMocks(); access.read = true; access.write = true
|
||||
vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [record], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(api.previewTripHandover).mockResolvedValue(preview); vi.mocked(api.confirmTripHandover).mockResolvedValue(record)
|
||||
})
|
||||
afterEach(() => wrappers.splice(0).forEach(row => row.unmount()))
|
||||
it('only loads one page and does not expose internal IDs or write controls without permission', async () => {
|
||||
access.write = false; const w = mount(); await flushPromises()
|
||||
expect(api.listTripHandovers).toHaveBeenCalledExactlyOnceWith('TR-ONE', 1, 10)
|
||||
expect(api.previewTripHandover).not.toHaveBeenCalled(); expect(w.text()).not.toContain('核对交接货物')
|
||||
expect(w.text()).not.toContain('结束原车辆任务'); expect(w.text()).not.toContain('secret-')
|
||||
})
|
||||
it('does not request data without list permission', async () => {
|
||||
access.read = false; const w = mount(); await flushPromises()
|
||||
expect(api.listTripHandovers).not.toHaveBeenCalled(); expect(w.text()).toContain('暂无车次交接查看权限')
|
||||
})
|
||||
it('invalidates preview when replacement or trip version changes', async () => {
|
||||
const w = mount(); await flushPromises(); await button(w, '核对交接货物').trigger('click'); await flushPromises()
|
||||
expect(w.text()).toContain('PC-A'); expect(w.text()).not.toContain('secret-'); expect(w.text()).not.toContain('private-hash')
|
||||
await w.setProps({ detail: { ...detail, trip: { ...detail.trip, recordVersion: 8 } } }); await flushPromises()
|
||||
expect(w.text()).not.toContain('确认实际交接')
|
||||
})
|
||||
it('retries exactly the original request after an unknown confirmation response', async () => {
|
||||
vi.mocked(api.confirmTripHandover).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(record)
|
||||
const w = mount(); await flushPromises()
|
||||
w.findAllComponents({ name: 'ElInput' })[0]!.vm.$emit('update:modelValue', '实际交接点')
|
||||
await button(w, '核对交接货物').trigger('click'); await flushPromises()
|
||||
await button(w, '确认实际交接').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.confirmTripHandover).mock.calls[0]
|
||||
expect(w.text()).toContain('上次交接结果尚待确认')
|
||||
await button(w, '重试原交接请求').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.confirmTripHandover).mock.calls[1]).toEqual(first)
|
||||
expect(w.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
it('ignores old trip response after switching scope', async () => {
|
||||
let complete!: (value: Awaited<ReturnType<typeof api.listTripHandovers>>) => void
|
||||
vi.mocked(api.listTripHandovers).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
|
||||
const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, tripNo: 'TR-TWO' } } }); await flushPromises()
|
||||
complete({ records: [{ ...record, handoverNo: 'OLD-RESULT' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(w.findComponent({ name: 'DataTable' }).props('data')[0].handoverNo).toBe('HO-ONE')
|
||||
})
|
||||
it('retains the original old-driver release request when its response is unknown', async () => {
|
||||
vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new Error('unknown response')).mockResolvedValueOnce({ ...record, recordVersion: 1, driverRelease: { actualEndAt: date, location: '结束地点', vehicleStatus: null } })
|
||||
const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises()
|
||||
await button(w, '结束原司机任务').trigger('click'); await flushPromises()
|
||||
w.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', '结束地点')
|
||||
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.releaseHandoverResource).mock.calls[0]
|
||||
expect(first?.[2].resourceKind).toBe('DRIVER'); expect(first?.[2].vehicleStatus).toBeUndefined()
|
||||
expect(w.text()).toContain('上次任务结束结果尚待确认')
|
||||
await button(w, '重试原任务结束请求').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]).toEqual(first)
|
||||
})
|
||||
it('reloads the handover version after a definite release conflict', async () => {
|
||||
vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new ApiResponseError('交接记录已变化', 'A0409')).mockResolvedValueOnce({ ...record, recordVersion: 2 })
|
||||
const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises()
|
||||
await button(w, '结束原司机任务').trigger('click'); await flushPromises()
|
||||
w.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', '结束地点')
|
||||
vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [{ ...record, recordVersion: 1 }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
|
||||
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].recordVersion).toBe(1)
|
||||
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].idempotencyKey).not.toBe(vi.mocked(api.releaseHandoverResource).mock.calls[0]?.[2].idempotencyKey)
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { confirmTripHandover, previewTripHandover, listTripHandovers, releaseHandoverResource, handoverNumber, handoverVersion,
|
||||
type HandoverCommand, type HandoverPreview, type HandoverRecord, type HandoverRelease, type HandoverSelection } from '../api/tripHandovers'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { DriverDetail } from '../driverTypes'
|
||||
|
||||
const props = defineProps<{ detail: TripDetail }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const readable = computed(() => access.can('platform:fleet:trip:list'))
|
||||
const writable = computed(() => readable.value && access.can('platform:fleet:trip:handover'))
|
||||
const releasable = computed(() => readable.value && access.can('platform:fleet:trip:release'))
|
||||
const rows = ref<HandoverRecord[]>([]), page = ref(1), size = ref(10), total = ref(0), loading = ref(false), busy = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), commandError = ref<ApiErrorDetails | null>(null)
|
||||
const preview = ref<HandoverPreview | null>(null), selected = ref<HandoverRecord | null>(null)
|
||||
const pending = ref<HandoverCommand | null>(null), pendingRelease = ref<{ number: string; command: HandoverRelease } | null>(null)
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||||
const vehicleLoading = ref(false), driverLoading = ref(false)
|
||||
const form = reactive({ vehicleId: '', driverId: '', handedOverAt: '', expectedDriverEndAt: '', location: '', reason: '' })
|
||||
const ending = ref<'VEHICLE' | 'DRIVER' | null>(null)
|
||||
const endForm = reactive({ actualEndAt: '', location: '', vehicleStatus: 'IN_SERVICE' })
|
||||
let scope = 0, query = 0, vehicleQuery = 0, driverQuery = 0, previewQuery = 0
|
||||
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'handoverNo', prop: 'handoverNo', label: '交接编号', minWidth: 220 },
|
||||
{ key: 'time', label: '实际交接时间', minWidth: 180, slotName: 'time' },
|
||||
{ key: 'vehicles', label: '交接前后车辆', minWidth: 200, slotName: 'vehicles' },
|
||||
{ key: 'drivers', label: '交接前后司机', minWidth: 180, slotName: 'drivers' },
|
||||
{ key: 'location', label: '交接地点', minWidth: 200, slotName: 'location' },
|
||||
]
|
||||
function canEnd(row: HandoverRecord, kind: 'VEHICLE' | 'DRIVER') {
|
||||
const vehicle = kind === 'VEHICLE', before = row.snapshot.previous, after = row.snapshot.replacement
|
||||
return releasable.value && (vehicle ? before.vehicleId !== after.vehicleId && !row.vehicleRelease && before.vehicleId !== props.detail.trip.vehicleId
|
||||
: before.driverId !== after.driverId && !row.driverRelease && before.driverId !== props.detail.trip.driverId)
|
||||
}
|
||||
function actions(row: HandoverRecord): RowActionItem[] {
|
||||
return [{ key: 'detail', label: '查看交接' }, ...(canEnd(row, 'VEHICLE') ? [{ key: 'VEHICLE', label: '结束原车辆任务' }] : []),
|
||||
...(canEnd(row, 'DRIVER') ? [{ key: 'DRIVER', label: '结束原司机任务' }] : [])]
|
||||
}
|
||||
function show(row: HandoverRecord, action: string) {
|
||||
if (busy.value || pending.value || pendingRelease.value) return
|
||||
selected.value = row; ending.value = action === 'VEHICLE' || action === 'DRIVER' ? action : null
|
||||
endForm.actualEndAt = new Date().toISOString(); endForm.location = ''; endForm.vehicleStatus = 'IN_SERVICE'; commandError.value = null
|
||||
}
|
||||
async function load() {
|
||||
const token = ++query
|
||||
if (!readable.value) { rows.value = []; total.value = 0; return }
|
||||
loading.value = true; error.value = null
|
||||
try { const result = await listTripHandovers(props.detail.trip.tripNo, page.value, size.value); if (token === query) { rows.value = result.records; total.value = result.total } }
|
||||
catch (cause) { if (token === query) error.value = getApiErrorDetails(cause, '交接记录加载失败') }
|
||||
finally { if (token === query) loading.value = false }
|
||||
}
|
||||
watch(() => props.detail.trip.tripNo, () => {
|
||||
scope++; query++; previewQuery++; vehicleQuery++; driverQuery++; pending.value = null; pendingRelease.value = null
|
||||
busy.value = false; preview.value = null; selected.value = null; ending.value = null; commandError.value = null
|
||||
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
|
||||
Object.assign(form, { vehicleId: props.detail.trip.vehicleId || '', driverId: props.detail.trip.driverId || '', handedOverAt: new Date().toISOString(), expectedDriverEndAt: props.detail.trip.expectedDriverEndAt, location: '', reason: '' })
|
||||
page.value = 1
|
||||
}, { immediate: true })
|
||||
watch([() => props.detail.trip.tripNo, page, size, readable], load, { immediate: true })
|
||||
watch([() => props.detail.trip.recordVersion, () => form.vehicleId, () => form.driverId, () => form.handedOverAt, () => form.expectedDriverEndAt], () => { previewQuery++; preview.value = null })
|
||||
async function searchVehicles(keyword = '') {
|
||||
const token = ++vehicleQuery; vehicleLoading.value = true
|
||||
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (token === vehicleQuery) vehicles.value = result.records }
|
||||
catch (cause) { if (token === vehicleQuery) commandError.value = getApiErrorDetails(cause, '车辆候选读取失败') }
|
||||
finally { if (token === vehicleQuery) vehicleLoading.value = false }
|
||||
}
|
||||
async function searchDrivers(keyword = '') {
|
||||
const token = ++driverQuery; driverLoading.value = true
|
||||
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: form.expectedDriverEndAt }); if (token === driverQuery) drivers.value = result.records }
|
||||
catch (cause) { if (token === driverQuery) commandError.value = getApiErrorDetails(cause, '司机候选读取失败') }
|
||||
finally { if (token === driverQuery) driverLoading.value = false }
|
||||
}
|
||||
function selection(): HandoverSelection {
|
||||
return { recordVersion: props.detail.trip.recordVersion, vehicleId: form.vehicleId, driverId: form.driverId,
|
||||
handedOverAt: new Date(form.handedOverAt).toISOString(), expectedDriverEndAt: new Date(form.expectedDriverEndAt).toISOString() }
|
||||
}
|
||||
async function inspect() {
|
||||
if (busy.value || pending.value || !writable.value) return
|
||||
if (!form.vehicleId || !form.driverId || !form.handedOverAt || !form.expectedDriverEndAt) { ElMessage.warning('请选择接替资源并填写交接及预计任务结束时间'); return }
|
||||
const token = ++previewQuery, generation = scope; busy.value = true; commandError.value = null; preview.value = null
|
||||
try { const result = await previewTripHandover(props.detail.trip.tripNo, selection()); if (token === previewQuery && generation === scope) preview.value = result }
|
||||
catch (cause) { if (generation === scope) commandError.value = getApiErrorDetails(cause, '交接核对未通过') }
|
||||
finally { if (generation === scope) busy.value = false }
|
||||
}
|
||||
async function confirm() {
|
||||
if (busy.value || !writable.value || (!pending.value && !preview.value)) return
|
||||
if (!pending.value && !form.location.trim()) { ElMessage.warning('请填写实际交接地点'); return }
|
||||
const generation = scope; busy.value = true; commandError.value = null
|
||||
try {
|
||||
pending.value ??= { selection: selection(), cargoHash: preview.value!.cargoHash, location: form.location.trim(), reason: form.reason.trim() || undefined, idempotencyKey: crypto.randomUUID() }
|
||||
const result = await confirmTripHandover(props.detail.trip.tripNo, pending.value)
|
||||
if (generation !== scope) return
|
||||
pending.value = null; preview.value = null; selected.value = result; emit('saved'); ElMessage.success('交接已记录,请分别核对原资源任务结束')
|
||||
await load()
|
||||
} catch (cause) { if (generation === scope) { if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pending.value = null; preview.value = null } commandError.value = getApiErrorDetails(cause, '交接未确认,请核对或重试') } }
|
||||
finally { if (generation === scope) busy.value = false }
|
||||
}
|
||||
async function release() {
|
||||
if (busy.value || !releasable.value || (!pendingRelease.value && (!selected.value || !ending.value))) return
|
||||
if (!pendingRelease.value && (!endForm.actualEndAt || !endForm.location.trim())) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
|
||||
const generation = scope; busy.value = true; commandError.value = null
|
||||
try {
|
||||
pendingRelease.value ??= { number: selected.value!.handoverNo, command: { recordVersion: handoverVersion(selected.value!.recordVersion), resourceKind: ending.value!, actualEndAt: new Date(endForm.actualEndAt).toISOString(), location: endForm.location.trim(), vehicleStatus: ending.value === 'VEHICLE' ? endForm.vehicleStatus : undefined, idempotencyKey: crypto.randomUUID() } }
|
||||
const result = await releaseHandoverResource(props.detail.trip.tripNo, pendingRelease.value.number, pendingRelease.value.command)
|
||||
if (generation !== scope) return
|
||||
pendingRelease.value = null; selected.value = result; ending.value = null; emit('saved'); ElMessage.success('原资源任务已结束'); await load()
|
||||
} catch (cause) {
|
||||
if (generation === scope) {
|
||||
commandError.value = getApiErrorDetails(cause, '任务结束未确认,请核对或重试')
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) {
|
||||
pendingRelease.value = null
|
||||
const number = selected.value?.handoverNo
|
||||
await load()
|
||||
if (generation === scope) {
|
||||
selected.value = rows.value.find(row => row.handoverNo === number) || null
|
||||
if (!selected.value) ending.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { if (generation === scope) busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driverQuery++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="在途交接" class="handover-panel">
|
||||
<p v-if="!readable">暂无车次交接查看权限。</p>
|
||||
<template v-else>
|
||||
<p>交接后原车辆和司机分别结束实际任务。已经签收或完成退回的货物保留原运输记录。</p>
|
||||
<LoadErrorState v-if="error" title="交接记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="writable && (detail.trip.status === 'IN_TRANSIT' || pending)">
|
||||
<p v-if="pending">上次交接结果尚待确认;重试使用原请求及原内容。</p>
|
||||
<el-form label-position="top" :disabled="busy || !!pending || !!pendingRelease" class="density-form handover-form">
|
||||
<el-form-item label="接替车辆" required><el-select v-model="form.vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" @focus="searchVehicles()">
|
||||
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="vehicle.plateNumber" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="接替司机" required><el-select v-model="form.driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" @focus="searchDrivers()">
|
||||
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="实际交接时间" required><el-date-picker v-model="form.handedOverAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="实际交接地点" required><el-input v-model="form.location" maxlength="500" placeholder="请输入实际交接地点" /></el-form-item>
|
||||
<el-form-item label="交接说明(选填)"><el-input v-model="form.reason" maxlength="1000" placeholder="如车辆故障或司机替班(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button :loading="busy" :disabled="!!pending || !!pendingRelease" @click="inspect">核对交接货物</el-button>
|
||||
<el-button :disabled="busy || !!pending || !!pendingRelease" @click="emit('saved')">刷新车次</el-button>
|
||||
<template v-if="preview">
|
||||
<p v-if="preview.driverQualification.expiringSoon">驾驶证将在30天内到期,当前预计任务结束仍在有效期内。</p>
|
||||
<p>本次交接 {{ preview.cargo.remainingPalletIds.length }} 个实托,占 {{ handoverNumber(preview.cargo.occupied.palletSpaces) }} 托位、{{ handoverNumber(preview.cargo.occupied.weightKg) }} kg、{{ handoverNumber(preview.cargo.occupied.volumeCbm) }} m³。</p>
|
||||
<table class="density-table"><thead><tr><th>客户排车单</th><th>交接箱数</th></tr></thead><tbody><tr v-for="(cargo, index) in preview.cargo.remainingCargo" :key="index"><td>{{ cargo.dispatchNo }}</td><td>{{ cargo.packages }}</td></tr></tbody></table>
|
||||
</template>
|
||||
<el-button v-if="preview || pending" type="primary" :loading="busy" @click="confirm">{{ pending ? '重试原交接请求' : '确认实际交接' }}</el-button>
|
||||
</div>
|
||||
<div v-if="commandError" role="alert"><p>{{ commandError.message }}</p><ApiErrorMeta :details="commandError" /></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="handoverNo" empty-text="暂无在途交接记录">
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as HandoverRecord)" @action="show(row as HandoverRecord, $event)" /></template>
|
||||
<template #time="{ row }">{{ time(row.snapshot.handedOverAt) }}</template>
|
||||
<template #vehicles="{ row }">{{ row.snapshot.previous.vehiclePlate || '—' }} → {{ row.snapshot.replacement.vehiclePlate || '—' }}</template>
|
||||
<template #drivers="{ row }">{{ row.snapshot.previous.driverName || '—' }} → {{ row.snapshot.replacement.driverName || '—' }}</template>
|
||||
<template #location="{ row }">{{ row.snapshot.location }}</template>
|
||||
</DataTable>
|
||||
<div v-if="selected" class="handover-detail">
|
||||
<h4>交接记录 {{ selected.handoverNo }}</h4>
|
||||
<p>{{ selected.snapshot.reason }} · {{ selected.snapshot.location }}</p>
|
||||
<p>原车辆任务:{{ selected.snapshot.previous.vehicleId === selected.snapshot.replacement.vehicleId ? '车辆继续执行' : selected.vehicleRelease ? `已结束 · ${time(selected.vehicleRelease.actualEndAt)} · ${selected.vehicleRelease.location}` : '尚未结束' }}</p>
|
||||
<p>原司机任务:{{ selected.snapshot.previous.driverId === selected.snapshot.replacement.driverId ? '司机继续执行' : selected.driverRelease ? `已结束 · ${time(selected.driverRelease.actualEndAt)} · ${selected.driverRelease.location}` : '尚未结束' }}</p>
|
||||
<p>交接前已完成派送:{{ selected.snapshot.cargo.completedDeliveries.map(row => row.deliveryNo).join('、') || '—' }}</p>
|
||||
<template v-if="ending || pendingRelease">
|
||||
<p v-if="pendingRelease">上次任务结束结果尚待确认;重试不会释放后续车次占用。</p>
|
||||
<el-form label-position="top" class="density-form handover-form" :disabled="busy || !!pendingRelease">
|
||||
<el-form-item label="实际任务结束时间" required><el-date-picker v-model="endForm.actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="实际任务结束地点" required><el-input v-model="endForm.location" maxlength="500" placeholder="请输入原车辆或司机实际结束任务的地点" /></el-form-item>
|
||||
<el-form-item v-if="ending === 'VEHICLE'" label="车辆实际状态" required><el-select v-model="endForm.vehicleStatus"><el-option value="IN_SERVICE" label="在役" /><el-option value="MAINTENANCE" label="维修中" /><el-option value="INACTIVE" label="停用" /><el-option value="RETURNED" label="已归还" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<el-button v-if="releasable" type="primary" :loading="busy" @click="release">{{ pendingRelease ? '重试原任务结束请求' : '确认原资源任务结束' }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.handover-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}
|
||||
.handover-form :deep(.el-select){width:100%}.handover-detail{margin-top:var(--admin-space-6)}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTripLoading, confirmTripLoading, getTripCapacityPlan, confirmTripCapacityPlan } from '../api/trips'
|
||||
import type { FleetTrip, TripLoading, TripCapacityPlanning, TripMeasurement } from '../tripTypes'
|
||||
|
||||
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean }>()
|
||||
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning] }>()
|
||||
type PalletRow = { warehousePalletId: string; palletNo: string; destinations: string; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }
|
||||
const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<PalletRow[]>([]), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let sequence = 0
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 },
|
||||
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 160 }, { key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 175 },
|
||||
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 190 }, { key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 190 },
|
||||
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 200 }])
|
||||
const savedLoad = computed(() => info.value && ('capacityPlan' in info.value ? info.value.capacityPlan : info.value.confirmedLoad))
|
||||
const segments = computed(() => info.value && 'availableSegments' in info.value ? info.value.availableSegments : savedLoad.value?.segments || [])
|
||||
const stalePlan = computed(() => info.value && 'planValid' in info.value && !!info.value.capacityPlan && !info.value.planValid)
|
||||
function apply(value: TripLoading | TripCapacityPlanning) {
|
||||
info.value = value
|
||||
const pallets = new Map<string, PalletRow>()
|
||||
for (const cargo of value.cargo) {
|
||||
if (!cargo.warehousePalletId) continue
|
||||
const existing = pallets.get(cargo.warehousePalletId)
|
||||
if (existing) { if (cargo.destinationLabel && !existing.destinations.split(' / ').includes(cargo.destinationLabel)) existing.destinations += ` / ${cargo.destinationLabel}`; continue }
|
||||
const saved = ('capacityPlan' in value ? value.capacityPlan : value.confirmedLoad)?.pallets.find(row => row.warehousePalletId === cargo.warehousePalletId)
|
||||
const index = props.trip.stops.findIndex(stop => !!cargo.destinationKey && stop.destinationKey === cargo.destinationKey)
|
||||
pallets.set(cargo.warehousePalletId, { warehousePalletId: cargo.warehousePalletId, palletNo: cargo.palletNo || '—', destinations: cargo.destinationLabel || '—',
|
||||
palletSpaces: saved?.palletSpaces ?? 1, weightKg: saved?.weightKg ?? null, volumeCbm: saved?.volumeCbm ?? null, deliveryIndex: saved?.deliveryIndex ?? (index > 0 ? index : null) })
|
||||
}
|
||||
rows.value = [...pallets.values()]
|
||||
}
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try { const result = await (props.planning ? getTripCapacityPlan(props.trip.tripNo) : getTripLoading(props.trip.tripNo)); if (current === sequence) apply(result) }
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, props.planning ? '计划容量加载失败' : '装载信息加载失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || !info.value) return
|
||||
if (!rows.value.length || rows.value.some(row => row.weightKg == null || row.volumeCbm == null || row.deliveryIndex == null)) { ElMessage.warning(props.planning ? '请填写每托预计重量、体积和卸货站' : '请填写每托实测重量、体积和卸货站'); return }
|
||||
const current = sequence, snapshot = info.value
|
||||
const pallets: TripMeasurement[] = rows.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces,
|
||||
weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! }))
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const body = { recordVersion: snapshot.recordVersion, manifestHash: snapshot.manifestHash, pallets, reason: props.planning ? '核对本车自有货计划载荷' : '核对实物托盘及实际装载' }
|
||||
if (props.planning) {
|
||||
const result = await confirmTripCapacityPlan(props.trip.tripNo, body)
|
||||
if (current === sequence) { apply(result); emit('planned', result); ElMessage.success('计划容量已核对') }
|
||||
} else {
|
||||
const result = await confirmTripLoading(props.trip.tripNo, body)
|
||||
if (current === sequence) { apply(result); emit('saved', result); ElMessage.success('实际装载已确认') }
|
||||
}
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, props.planning ? '计划容量确认失败' : '装载确认失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
watch(() => [props.trip.tripNo, props.trip.recordVersion, props.planning], () => { if (info.value?.tripNo !== props.trip.tripNo || info.value.recordVersion !== props.trip.recordVersion || !!props.planning !== ('capacityPlan' in info.value)) void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<LoadErrorState v-if="error" :title="planning ? '计划容量操作未完成' : '装载操作未完成'" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="planning">先核对本车自有货的预计载荷,查看每段剩余托位、载重和容积。实际装载完成后仍需按实测值确认。</p>
|
||||
<p v-if="stalePlan" role="status">货物明细已变化,请重新核对计划容量。</p>
|
||||
<p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p>
|
||||
<p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p>
|
||||
<p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p>
|
||||
<DataTable :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
|
||||
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
|
||||
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
|
||||
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
|
||||
<template #stop="{ row }"><el-select v-model="row.deliveryIndex" :disabled="!editable || busy" placeholder="选择卸货站"><el-option v-for="(stop, index) in trip.stops.slice(1)" :key="index" :value="index + 1" :label="stop.name" /></el-select></template>
|
||||
<template #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="!!info?.blockReason || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
|
||||
</DataTable>
|
||||
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TripOperationsCharts, TripOperationsSeries } from '../api/tripOperations'
|
||||
const props = defineProps<{ charts: TripOperationsCharts }>()
|
||||
const sections = computed(() => [
|
||||
{ title: '实运托盘', note: '按实际发车日(UTC)汇总,物理托盘去重', series: props.charts.physicalPallets },
|
||||
{ title: '派送目的地', note: '各目的地涉及的车次,同车多站可分别出现', series: props.charts.destinationTrips },
|
||||
{ title: '外接约定金额', note: '按币种分别展示,不代表财务应收或实收', series: props.charts.externalAgreedAmounts },
|
||||
{ title: '外接按托报价均价', note: '按确认计价托位加权,排除整单报价', series: props.charts.externalPalletRates },
|
||||
])
|
||||
function width(value: number, series: TripOperationsSeries) { const max = Math.max(...series.points.map(p => p.value), 0); return max ? value / max * 100 : 0 }
|
||||
</script>
|
||||
<template><section class="operations-charts" aria-label="出车数据统计"><article v-for="section in sections" :key="section.title" class="chart">
|
||||
<strong>{{ section.title }}</strong><p>{{ section.note }}</p>
|
||||
<p v-if="!section.series.some(s => s.points.length)">暂无对应统计</p>
|
||||
<div class="chart-values"><template v-for="(series, si) in section.series" :key="`${series.label}-${si}`"><div v-if="series.points.length"><b>{{ series.label }}({{ series.unit }})</b>
|
||||
<div v-for="(point, pi) in series.points" :key="pi" class="point"><span>{{ point.label }}</span><div class="bar-track" aria-hidden="true"><div class="bar" :style="{ width: width(point.value, series) + '%' }" /></div><span>{{ point.value }}</span></div>
|
||||
</div></template></div>
|
||||
</article></section></template>
|
||||
<style scoped>
|
||||
.operations-charts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--admin-space-3); }
|
||||
.chart { display:grid; align-content:start; gap:var(--admin-space-2); min-width:0; padding:var(--admin-space-3); border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:var(--admin-surface); }
|
||||
.chart > strong { font-size:var(--admin-font-section); }
|
||||
.chart p { margin:0; line-height:1.6; color:var(--cc-muted); font-size:var(--admin-font-caption); }
|
||||
.chart-values { max-height:220px; overflow:auto; }
|
||||
.point { display:grid; grid-template-columns:minmax(95px,1fr) minmax(45px,1fr) minmax(40px,auto); gap:var(--admin-space-2); align-items:center; margin:var(--admin-space-2) 0; }
|
||||
.bar-track { height:10px; background:var(--el-fill-color-light); }.bar { height:100%; background:var(--el-color-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import Cell from './TripOperationsFinanceCell.vue'
|
||||
import * as api from '../api/tripOperationsFinance'
|
||||
const access = vi.hoisted(() => ({ read: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
|
||||
vi.mock('../api/tripOperationsFinance', () => ({ getTripOperationsFinance: vi.fn() }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount() { const w = shallowMount(Cell, { props: { tripNo: 'TR-A' }, global: { stubs: { ElButton: { template: '<button><slot/></button>' }, ElPopover: { template: '<div><slot/><slot name="reference"/></div>' }, ApiErrorMeta: true } } }); wrappers.push(w); return w }
|
||||
const result: api.TripFinanceResult = { tripNo: 'TR-A', dispatchNos: ['DS-A'], receiptsVisible: true, attributionStatus: 'ATTRIBUTED', attributionReason: 'frozen', finance: { status: 'AVAILABLE', totals: [{ currency: 'CNY', receivableAmount: 20, receivedAmount: null }], sources: [], documents: [{ billNo: 'BILL-A', currency: 'CNY', receivedAmount: null, allocationStatus: 'PENDING_ALLOCATION', reason: '混合来源' }], reason: 'authority' } }
|
||||
beforeEach(() => { vi.resetAllMocks(); access.read = true; vi.mocked(api.getTripOperationsFinance).mockResolvedValue(structuredClone(result)) })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it('loads only on explicit click and displays pending without inventing zero', async () => {
|
||||
const w = mount(); await flushPromises(); expect(api.getTripOperationsFinance).not.toHaveBeenCalled()
|
||||
await w.find('button').trigger('click'); await flushPromises()
|
||||
expect(api.getTripOperationsFinance).toHaveBeenCalledExactlyOnceWith('TR-A')
|
||||
expect(w.text()).toContain('应收 20.00 / 实收 待分配'); expect(w.text()).toContain('BILL-A'); expect(w.text()).not.toContain('实收 0.00')
|
||||
})
|
||||
it('does not fetch without existing financial permission', async () => { access.read = false; const w = mount(); await flushPromises(); expect(w.text()).toContain('无应收读取权限'); expect(api.getTripOperationsFinance).not.toHaveBeenCalled() })
|
||||
it('ignores a response arriving after switching trip', async () => {
|
||||
let resolve!: (v: api.TripFinanceResult) => void
|
||||
vi.mocked(api.getTripOperationsFinance).mockImplementation(() => new Promise(r => { resolve = r }))
|
||||
const w = mount(); await w.find('button').trigger('click'); await w.setProps({ tripNo: 'TR-B' }); resolve(result); await flushPromises()
|
||||
expect(w.text()).not.toContain('20.00'); expect(w.text()).toContain('查看应收/实收')
|
||||
})
|
||||
it('separates no receipt permission from unallocated values', async () => {
|
||||
vi.mocked(api.getTripOperationsFinance).mockResolvedValue({ ...result, receiptsVisible: false, finance: { ...result.finance, documents: [] } })
|
||||
const w = mount(); await w.find('button').trigger('click'); await flushPromises(); expect(w.text()).toContain('实收 无读取权限')
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getTripOperationsFinance, type TripFinanceResult } from '../api/tripOperationsFinance'
|
||||
const props = defineProps<{ tripNo: string }>()
|
||||
const access = useAccessPreviewStore(), result = ref<TripFinanceResult | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let sequence = 0
|
||||
async function load() {
|
||||
if (!access.can('platform:finance:customer-arap:list') || !access.can('platform:fleet:operations:list') || loading.value) return
|
||||
const current = ++sequence, tripNo = props.tripNo; loading.value = true; error.value = null
|
||||
try { const data = await getTripOperationsFinance(tripNo); if (current === sequence) result.value = data }
|
||||
catch (cause) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(cause, '财务读取失败') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
watch(() => props.tripNo, () => { sequence++; result.value = null; error.value = null; loading.value = false })
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<span v-if="!access.can('platform:finance:customer-arap:list')">无应收读取权限</span>
|
||||
<template v-else>
|
||||
<el-button v-if="!result" class="density-action" :loading="loading" @click="load">{{ error ? '重试财务读取' : '查看应收/实收' }}</el-button>
|
||||
<span v-if="error">{{ error.message }}<ApiErrorMeta :details="error" /></span>
|
||||
<template v-if="result">
|
||||
<div v-for="m in result.finance.totals" :key="m.currency">{{ m.currency }} 应收 {{ m.receivableAmount.toFixed(2) }} / 实收 {{ !result.receiptsVisible ? '无读取权限' : m.receivedAmount == null ? '待分配' : m.receivedAmount.toFixed(2) }}</div>
|
||||
<span v-if="!result.finance.totals.length || result.finance.status === 'PENDING_ALLOCATION' || result.attributionStatus === 'PENDING_ALLOCATION'">完整金额待分配</span>
|
||||
<el-popover trigger="click" :width="620" placement="left">
|
||||
<div class="finance-source-details"><p>{{ result.attributionReason }}</p><p>{{ result.finance.reason }}</p>
|
||||
<p>排车来源:{{ result.dispatchNos.join(' / ') || '—' }}</p>
|
||||
<div v-for="s in result.finance.sources" :key="`${s.customerId}:${s.businessNo}:${s.sourceNo}:${s.feeItemName}:${s.sourceVersion}`">{{ s.businessNo }} · {{ s.sourceNo || '—' }} · {{ s.feeItemName }} · 版本 {{ s.sourceVersion }} · {{ s.currency }} {{ s.amount.toFixed(2) }}</div>
|
||||
<div v-for="d in result.finance.documents" :key="d.billNo">{{ d.billNo }} · {{ d.currency }} {{ d.receivedAmount == null ? '待分配' : d.receivedAmount.toFixed(2) }} · {{ d.reason }}</div>
|
||||
</div><template #reference><el-button class="density-action">金额来源</el-button></template>
|
||||
</el-popover>
|
||||
<el-button class="density-action" :loading="loading" @click="load">刷新</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>.finance-source-details { max-height: 50vh; overflow: auto; }</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { mount as mountFull, shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus'
|
||||
import TripOperationsRecordDialog from './TripOperationsRecordDialog.vue'
|
||||
import * as api from '../api/tripOperations'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
vi.mock('../api/tripOperations', () => ({ getTripOperationsRecord: vi.fn(), saveTripOperationsRecord: vi.fn(), listTripOperationsRecordHistory: vi.fn(), uploadTripOperationsEvidence: vi.fn(), downloadTripOperationsEvidence: vi.fn() }))
|
||||
const row: api.TripOperationsRow = { tripNo: 'TR1', status: 'IN_TRANSIT', vehiclePlate: 'TEST-1', driverName: '司机', warehouseName: '起运仓', departedAt: '2026-10-01T00:00:00Z', completedAt: null, destinations: [], physicalPallets: 1, palletSpaces: 1, ownPackages: 1, externalPackages: 0, externalAgreedAmounts: [], externalPalletRates: [], measured: null, financeStatus: 'NOT_QUERIED', costStatus: 'FORBIDDEN', costs: null }
|
||||
const record: api.TripOperationsRecord = { tripNo: 'TR1', recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] }, updatedAt: null }
|
||||
const wrappers: VueWrapper[] = []
|
||||
const slot = { template: '<div><slot/></div>' }
|
||||
const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',Number($event.target.value))" />' }
|
||||
async function mount(readonly = false, realControls = false) {
|
||||
const wrapper = (realControls ? mountFull : shallowMount)(TripOperationsRecordDialog, { props: { modelValue: false, row, readonly }, global: { components: { ElForm, ElFormItem, ElInput, ElInputNumber }, stubs: {
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot/><slot name="footer"/></div>' }, ElForm: realControls ? false : slot, ElFormItem: realControls ? false : slot,
|
||||
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: slot, ElInputNumber: realControls ? false : input,
|
||||
ElInput: realControls ? false : { ...input, name: 'ElInput' }, ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' },
|
||||
ElAlert: { props: ['title'], template: '<div>{{ title }}<slot/></div>' }, ApiErrorMeta: true, DataTable: true,
|
||||
} } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises(); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record); vi.mocked(api.listTripOperationsRecordHistory).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it.each([[null, 0], [0, null]])('delayed records keep distance %s and fuel %s editable in real number controls', async (distance, fuel) => {
|
||||
let resolve!: (value: api.TripOperationsRecord) => void
|
||||
vi.mocked(api.getTripOperationsRecord).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const wrapper = await mount(false, true)
|
||||
expect(wrapper.findComponent(ElForm).exists()).toBe(false)
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '保存记录')!.attributes('disabled')).toBeDefined()
|
||||
resolve({ ...record, details: { ...record.details, actualDistanceMiles: distance, actualFuelLitres: fuel } })
|
||||
await flushPromises()
|
||||
const controls = wrapper.findAllComponents(ElInputNumber)
|
||||
expect(controls.map(control => control.props('modelValue'))).toEqual([distance, fuel])
|
||||
const inputs = controls.map(control => control.find('input'))
|
||||
expect(inputs.map(input => (input.element as HTMLInputElement).value)).toEqual([distance === null ? '' : '0.0', fuel === null ? '' : '0.0'])
|
||||
for (const input of inputs) {
|
||||
expect((input.element as HTMLInputElement).disabled).toBe(false)
|
||||
expect((input.element as HTMLInputElement).readOnly).toBe(false)
|
||||
expect(input.attributes('aria-disabled')).toBe('false')
|
||||
}
|
||||
await inputs[0]!.setValue('12.5')
|
||||
await inputs[0]!.trigger('change')
|
||||
expect(controls[0]!.props('modelValue')).toBe(12.5)
|
||||
await inputs[0]!.setValue(distance === null ? '' : '0')
|
||||
await inputs[0]!.trigger('change')
|
||||
expect(controls[0]!.props('modelValue')).toBe(distance)
|
||||
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue(record)
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存记录')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1].details).toMatchObject({ actualDistanceMiles: distance, actualFuelLitres: fuel })
|
||||
})
|
||||
it('delayed read-only records keep real number controls disabled', async () => {
|
||||
let resolve!: (value: api.TripOperationsRecord) => void
|
||||
vi.mocked(api.getTripOperationsRecord).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const wrapper = await mount(true, true)
|
||||
expect(wrapper.findComponent(ElForm).exists()).toBe(false)
|
||||
resolve({ ...record, details: { ...record.details, actualFuelLitres: 0 } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAllComponents(ElInputNumber).map(control => control.props('modelValue'))).toEqual([null, 0])
|
||||
for (const control of wrapper.findAllComponents(ElInputNumber)) {
|
||||
const input = control.find('input')
|
||||
expect((input.element as HTMLInputElement).disabled).toBe(true)
|
||||
expect(input.attributes('aria-disabled')).toBe('true')
|
||||
}
|
||||
expect(wrapper.text()).not.toContain('保存记录')
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
it('loads current record on open, history only when selected, and hides writes for readers', async () => {
|
||||
const wrapper = await mount(true)
|
||||
expect(api.getTripOperationsRecord).toHaveBeenCalledExactlyOnceWith('TR1'); expect(api.listTripOperationsRecordHistory).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('保存记录')
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
|
||||
expect(api.listTripOperationsRecordHistory).toHaveBeenCalledExactlyOnceWith('TR1', 1, 20)
|
||||
})
|
||||
it('unknown write retry preserves original key, version and measured values', async () => {
|
||||
const wrapper = await mount(); vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network'))
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 12.5); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
||||
expect(original).toMatchObject({ recordVersion: '0', details: { actualDistanceMiles: 12.5, actualFuelLitres: null } })
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 99)
|
||||
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
||||
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
it('a failed initial read cannot save a guessed record version', async () => {
|
||||
vi.mocked(api.getTripOperationsRecord).mockRejectedValue(new Error('unavailable')); const wrapper = await mount()
|
||||
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeDefined()
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
const evidence: api.TripOperationsEvidence = { fileId: '9007199254740993', fileName: 'meter.pdf', contentType: 'application/pdf', sizeBytes: 4 }
|
||||
async function choose(wrapper: VueWrapper, file = new File(['test'], 'meter.pdf', { type: 'application/pdf' })) {
|
||||
const input = wrapper.find('[data-testid="evidence-input"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises(); return file
|
||||
}
|
||||
it('uploads optionally without creating records; saving binds only the exact IDs and unknown save retains them', async () => {
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockResolvedValue(evidence)
|
||||
vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ ...record, recordVersion: '1' })
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('meter.pdf')
|
||||
expect(wrapper.text()).toContain('保存后可预览'); expect(wrapper.findAll('button').find(b => b.text() === 'meter.pdf')!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
||||
const request = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
||||
expect(request.details.evidenceFileIds).toEqual([evidence.fileId]); expect(request.details).not.toHaveProperty('evidenceFiles')
|
||||
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(request)
|
||||
})
|
||||
it('a committed upload with lost response never retransmits after reopen or reload and preserves bound evidence', async () => {
|
||||
const existing = { ...evidence, fileId: '81', fileName: 'bound.pdf' }
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [existing] } })
|
||||
let storedFiles = 0
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockImplementation(async () => { storedFiles++; throw new Error('response lost after storage commit') })
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
expect(storedFiles).toBe(1); expect(wrapper.text()).toContain('可能已上传但尚未绑定'); expect(wrapper.text()).toContain('bound.pdf')
|
||||
expect(wrapper.text()).not.toContain('重试原文件'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('可能已上传但尚未绑定')
|
||||
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('button').find(b => b.text() === '重新读取')!.trigger('click'); await flushPromises()
|
||||
await choose(wrapper)
|
||||
expect(storedFiles).toBe(1); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(b => b.text() === '暂不绑定此附件')!.trigger('click'); await flushPromises()
|
||||
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
||||
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1].details.evidenceFileIds).toEqual(['81'])
|
||||
expect(storedFiles).toBe(1)
|
||||
const refreshed = await mount(); expect(refreshed.text()).toContain('bound.pdf'); expect(storedFiles).toBe(1)
|
||||
})
|
||||
it('explicit upload rejection allows corrected file selection without creating a record', async () => {
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockRejectedValueOnce(new ApiResponseError('文件格式不符', 'A0400', 400)).mockResolvedValueOnce(evidence)
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('可能已上传但尚未绑定')
|
||||
const corrected = new File(['new'], 'corrected.pdf', { type: 'application/pdf' }); await choose(wrapper, corrected)
|
||||
expect(api.uploadTripOperationsEvidence).toHaveBeenNthCalledWith(2, 'TR1', corrected)
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
it('late upload from another trip cannot attach a file to the newly opened record', async () => {
|
||||
let done!: (value: api.TripOperationsEvidence) => void
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' })
|
||||
await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises(); done(evidence); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('meter.pdf'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record)
|
||||
await wrapper.setProps({ row }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
||||
})
|
||||
it('upload completing on history tab remains recoverable on the same trip', async () => {
|
||||
let done!: (value: api.TripOperationsEvidence) => void
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
|
||||
done(evidence); await flushPromises()
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'measured'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
||||
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeUndefined()
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preview is fetched only on explicit click and stale response cannot appear on another trip', async () => {
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [evidence] } })
|
||||
let done!: (value: Blob) => void
|
||||
vi.mocked(api.downloadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(true); expect(api.downloadTripOperationsEvidence).not.toHaveBeenCalled(); expect(wrapper.find('[data-testid="evidence-input"]').exists()).toBe(false)
|
||||
await wrapper.findAll('button').find(b => b.text() === 'meter.pdf')!.trigger('click')
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' })
|
||||
await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises(); done(new Blob(['pdf'], { type: 'application/pdf' })); await flushPromises()
|
||||
expect(wrapper.find('iframe').exists()).toBe(false); expect(api.downloadTripOperationsEvidence).toHaveBeenCalledExactlyOnceWith('TR1', evidence.fileId)
|
||||
})
|
||||
it('late save cannot close another trip dialog', async () => {
|
||||
let done!: (value: api.TripOperationsRecord) => void
|
||||
vi.mocked(api.saveTripOperationsRecord).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' }); await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises()
|
||||
done({ ...record, recordVersion: '1' }); await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined(); expect(wrapper.text()).toContain('TR2')
|
||||
})
|
||||
it('closing and reopening the same trip cannot start a concurrent save retry', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
vi.mocked(api.saveTripOperationsRecord).mockReturnValue(new Promise((_, fail) => { reject = fail }))
|
||||
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
|
||||
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const retry = wrapper.findAll('button').find(b => b.text() === '按原请求重试')!
|
||||
expect(retry.attributes('disabled')).toBeDefined(); await retry.trigger('click'); expect(api.saveTripOperationsRecord).toHaveBeenCalledTimes(1)
|
||||
reject(new Error('unknown')); await flushPromises()
|
||||
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
||||
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original)
|
||||
})
|
||||
it('closing and reopening the same trip cannot duplicate an in-flight upload', async () => {
|
||||
let done!: (value: api.TripOperationsEvidence) => void
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('重试原文件'); await choose(wrapper); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1)
|
||||
done(evidence); await flushPromises()
|
||||
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
||||
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { isFleetCommandRejected } from '../api/fleetQuotes'
|
||||
import { getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, uploadTripOperationsEvidence, downloadTripOperationsEvidence, type TripOperationsEvidence, type TripOperationsRow, type TripOperationsRecord, type TripOperationsRecordHistory, type TripOperationsRecordSave, type TripOperationsMeasured } from '../api/tripOperations'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ row: TripOperationsRow | null; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const form = reactive<TripOperationsMeasured>({ actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] }), reason = ref('')
|
||||
const record = ref<TripOperationsRecord | null>(null), loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), tab = ref('measured')
|
||||
const pending = new Map<string, TripOperationsRecordSave>(), uncertain = ref(false)
|
||||
const pendingEvidence = new Map<string, TripOperationsEvidence[]>()
|
||||
const activeSaves = new Set<string>()
|
||||
const uploadedDrafts = new Map<string, TripOperationsEvidence[]>()
|
||||
const savedFileIds = ref(new Set<string>())
|
||||
const uploading = ref(false), uploadUncertain = ref(false), uploadName = ref('')
|
||||
const pendingUploads = new Map<string, { name: string; diagnostic?: ApiErrorDetails }>()
|
||||
const activeUploads = new Set<string>()
|
||||
const previewUrl = ref(''), previewFile = ref<TripOperationsEvidence | null>(null), previewLoading = ref(false)
|
||||
let previewSequence = 0
|
||||
function clearPreview() { previewSequence++; if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = ''; previewFile.value = null; previewLoading.value = false }
|
||||
async function preview(file: TripOperationsEvidence) {
|
||||
if (!props.row || !open.value) return
|
||||
clearPreview(); const current = previewSequence, no = props.row.tripNo; previewLoading.value = true
|
||||
try { const blob = await downloadTripOperationsEvidence(no, file.fileId); if (current !== previewSequence || props.row?.tripNo !== no || !open.value) return; previewUrl.value = URL.createObjectURL(blob); previewFile.value = file }
|
||||
catch (cause) { if (current === previewSequence) error.value = getApiErrorDetails(cause, '凭证读取失败') }
|
||||
finally { if (current === previewSequence) previewLoading.value = false }
|
||||
}
|
||||
async function upload(file: File) {
|
||||
if (!props.row || !record.value || props.readonly || saving.value || uploading.value || uncertain.value || loading.value) return
|
||||
const no = props.row.tripNo, selected = file
|
||||
if (!selected || form.evidenceFiles.length >= 20 || activeUploads.has(no) || pendingUploads.has(no)) return
|
||||
if (!selected.size || selected.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(selected.type)) { error.value = { message: '请选择非空PDF、JPG或PNG文件,最大10MiB' }; return }
|
||||
const current = sequence, attempt = { name: selected.name, diagnostic: undefined as ApiErrorDetails | undefined }; activeUploads.add(no); uploading.value = true; error.value = null; pendingUploads.set(no, attempt); uploadName.value = selected.name
|
||||
try {
|
||||
const result = await uploadTripOperationsEvidence(no, selected)
|
||||
uploadedDrafts.set(no, [...(uploadedDrafts.get(no) || []).filter(file => file.fileId !== result.fileId), result])
|
||||
if (pendingUploads.get(no) === attempt) pendingUploads.delete(no)
|
||||
if (current !== sequence || props.row?.tripNo !== no || !open.value) return
|
||||
form.evidenceFiles = [...form.evidenceFiles, result]; uploadUncertain.value = false; uploadName.value = ''
|
||||
} catch (cause) {
|
||||
attempt.diagnostic = getApiErrorDetails(cause, '凭证上传结果未确认,可能已上传但尚未绑定,请先核对');
|
||||
if (isFleetCommandRejected(cause) && pendingUploads.get(no) === attempt) pendingUploads.delete(no)
|
||||
if (current === sequence) { error.value = attempt.diagnostic; uploadUncertain.value = pendingUploads.has(no) }
|
||||
} finally { activeUploads.delete(no); if (props.row?.tripNo === no) { uploading.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
|
||||
}
|
||||
function chooseFile(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (file) void upload(file) }
|
||||
function omitUpload() { if (!props.row || uploading.value || activeUploads.has(props.row.tripNo)) return; pendingUploads.delete(props.row.tripNo); uploadUncertain.value = false; uploadName.value = '' }
|
||||
function removeFile(fileId: string) { if (props.readonly || saving.value || uncertain.value || uploading.value) return; form.evidenceFiles = form.evidenceFiles.filter(file => file.fileId !== fileId); if (props.row) uploadedDrafts.set(props.row.tripNo, (uploadedDrafts.get(props.row.tripNo) || []).filter(file => file.fileId !== fileId)); clearPreview() }
|
||||
const history = ref<TripOperationsRecordHistory[]>([]), total = ref(0), page = ref(1), size = ref(20)
|
||||
let sequence = 0
|
||||
const columns: DataTableColumn[] = [{ key: 'recordVersion', label: '版本', width: 90 }, { key: 'occurredAt', label: '更正时间', minWidth: 190 }, { key: 'distance', label: '里程(mile)', width: 130, slotName: 'distance' }, { key: 'fuel', label: '油耗(L)', width: 120, slotName: 'fuel' }, { key: 'reason', label: '更正说明', minWidth: 180 }, { key: 'remark', label: '备注', minWidth: 200, slotName: 'remark' }, { key: 'evidence', label: '凭证', minWidth: 170, slotName: 'evidence' }]
|
||||
async function load() {
|
||||
if (!open.value || !props.row) return
|
||||
const no = props.row.tripNo, current = ++sequence; loading.value = true; error.value = null; record.value = null
|
||||
uploading.value = activeUploads.has(no)
|
||||
saving.value = activeSaves.has(no)
|
||||
try { const result = await getTripOperationsRecord(no); if (current !== sequence) return; record.value = result; savedFileIds.value = new Set(result.details.evidenceFiles.map(file => file.fileId)); const retry = pending.get(no); uncertain.value = !!retry; Object.assign(form, retry?.details || result.details); form.evidenceFiles = (retry ? pendingEvidence.get(no) || [] : [...result.details.evidenceFiles, ...(props.readonly ? [] : uploadedDrafts.get(no) || []).filter(file => !savedFileIds.value.has(file.fileId))]).map(file => ({ ...file })); reason.value = retry?.reason || ''; uploadUncertain.value = pendingUploads.has(no); uploadName.value = pendingUploads.get(no)?.name || ''; if (pendingUploads.get(no)?.diagnostic) error.value = pendingUploads.get(no)!.diagnostic! }
|
||||
catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '实测记录读取失败') } finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function loadHistory() {
|
||||
if (!open.value || !props.row || tab.value !== 'history') return
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try { const result = await listTripOperationsRecordHistory(props.row.tripNo, page.value, size.value); if (current === sequence) { history.value = result.records; total.value = result.total } } catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '更正历史读取失败') } finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!record.value || !props.row || props.readonly || saving.value || uploading.value || uploadUncertain.value || loading.value) return
|
||||
const no = props.row.tripNo, current = sequence
|
||||
if (activeSaves.has(no)) return
|
||||
activeSaves.add(no); saving.value = true; error.value = null
|
||||
const request = pending.get(no) || { recordVersion: record.value.recordVersion, idempotencyKey: crypto.randomUUID(), details: { actualDistanceMiles: form.actualDistanceMiles ?? null, actualFuelLitres: form.actualFuelLitres ?? null, remark: form.remark || null, evidenceFileIds: form.evidenceFiles.map(file => file.fileId) }, reason: reason.value || null }
|
||||
if (!pending.has(no)) pendingEvidence.set(no, form.evidenceFiles.map(file => ({ ...file })))
|
||||
pending.set(no, request)
|
||||
try { await saveTripOperationsRecord(no, request); if (pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } uploadedDrafts.set(no, (uploadedDrafts.get(no) || []).filter(file => !request.details.evidenceFileIds.includes(file.fileId))); emit('saved'); if (current === sequence) { uncertain.value = false; open.value = false } }
|
||||
catch (e) { if (isFleetCommandRejected(e) && pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } if (current === sequence) { error.value = getApiErrorDetails(e, '实测记录保存失败'); uncertain.value = pending.has(no) } }
|
||||
finally { activeSaves.delete(no); if (props.row?.tripNo === no) { saving.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
|
||||
}
|
||||
watch([open, () => props.row?.tripNo], () => { sequence++; clearPreview(); uploading.value = false; saving.value = false; uploadUncertain.value = false; uncertain.value = false; form.evidenceFiles = []; record.value = null; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
|
||||
watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') void loadHistory(); else if (tab.value === 'measured') void load() }); onBeforeUnmount(() => { sequence++; clearPreview() })
|
||||
</script>
|
||||
<template><AppDialog v-model="open" title="出车实测记录" width="820px" class="admin-density-dialog" :close-on-click-modal="false">
|
||||
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /><el-button @click="tab === 'history' ? loadHistory() : load()">重新读取</el-button></el-alert>
|
||||
<p v-if="row">{{ row.tripNo }} · {{ row.vehiclePlate || '—' }} · 实际发车 {{ row.departedAt }} · 货物结案 {{ row.completedAt || '—' }}</p>
|
||||
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs"><el-tab-pane label="实测记录" name="measured">
|
||||
<p v-if="uncertain">上次保存结果未确认,将按原请求重试。</p>
|
||||
<el-form v-if="record" label-position="top" class="density-form record-form" :disabled="readonly || !record || loading || saving || uploading || uncertain">
|
||||
<el-form-item label="本趟实际里程(mile)"><el-input-number :model-value="form.actualDistanceMiles" @update:model-value="form.actualDistanceMiles = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际英里数" /></el-form-item>
|
||||
<el-form-item label="本趟实际油耗(L)"><el-input-number :model-value="form.actualFuelLitres" @update:model-value="form.actualFuelLitres = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际用油升数" /></el-form-item>
|
||||
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充实测情况" /></el-form-item>
|
||||
<el-form-item v-if="!readonly" label="更正说明(选填)" class="full"><el-input v-model="reason" maxlength="1000" placeholder="选填,留空使用标准更正说明" /></el-form-item>
|
||||
<el-form-item label="实测凭证(选填)" class="full">
|
||||
<input v-if="!readonly" data-testid="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" aria-label="选择实测凭证" :disabled="!record || loading || saving || uploading || uncertain || uploadUncertain || form.evidenceFiles.length >= 20" @change="chooseFile" />
|
||||
<p>PDF、JPG、PNG,单个不超过10MiB,最多20个;不上传也可保存。</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<ul v-if="form.evidenceFiles.length" class="evidence-list"><li v-for="file in form.evidenceFiles" :key="file.fileId"><el-button :disabled="previewLoading || !savedFileIds.has(file.fileId)" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!savedFileIds.has(file.fileId)">(保存后可预览)</span><el-button v-if="!readonly" :disabled="saving || uploading || uncertain" @click="removeFile(file.fileId)">移除引用</el-button></li></ul>
|
||||
<p v-if="uploading">正在上传 {{ uploadName }}…</p>
|
||||
<p v-if="uploadUncertain">{{ uploadName }} 的上传结果未确认,可能已上传但尚未绑定。请先核对,不要重复上传。<el-button :disabled="uploading" @click="omitUpload">暂不绑定此附件</el-button></p>
|
||||
<p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p>
|
||||
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template><template #evidence="{ row: item }"><el-button v-for="file in item.details.evidenceFiles" :key="file.fileId" :disabled="previewLoading" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!item.details.evidenceFiles.length">—</span></template></DataTable></el-tab-pane></el-tabs>
|
||||
<section v-if="previewUrl && previewFile" aria-label="实测凭证预览"><el-button @click="clearPreview">关闭预览</el-button><iframe v-if="previewFile.contentType === 'application/pdf'" :src="previewUrl" :title="previewFile.fileName" class="evidence-preview" /><img v-else :src="previewUrl" :alt="previewFile.fileName" class="evidence-preview" /></section>
|
||||
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading || uploading || uploadUncertain" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
|
||||
</AppDialog></template>
|
||||
<style scoped>.record-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }.evidence-list{padding-left:var(--admin-space-4)}.evidence-preview{display:block;width:100%;max-height:360px;object-fit:contain}.evidence-preview:is(iframe){height:360px;border:0}</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listDriverWarehouses } from '../api/drivers'
|
||||
import { createTrip, updateTripPlan } from '../api/trips'
|
||||
import type { DriverWarehouseOption } from '../driverTypes'
|
||||
import type { FleetTrip, TripDetail, TripDraft, TripStop } from '../tripTypes'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: FleetTrip | null }>()
|
||||
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
|
||||
const form = reactive({ warehouseId: '', plannedDepartureAt: '', expectedDriverEndAt: '', remark: '', reason: '' })
|
||||
const stops = ref<TripStop[]>([]), warehouses = ref<DriverWarehouseOption[]>([])
|
||||
const saving = ref(false), error = ref<ApiErrorDetails | null>(null), warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<TripDraft | null>(null)
|
||||
let generation = 0
|
||||
async function loadWarehouses() {
|
||||
const current = generation
|
||||
warehouseError.value = null
|
||||
try { const result = await listDriverWarehouses('OVERSEAS'); if (current === generation) warehouses.value = result }
|
||||
catch (cause) { if (current === generation) warehouseError.value = getApiErrorDetails(cause, '起运仓加载失败') }
|
||||
}
|
||||
watch([open, () => props.trip?.tripNo], ([visible]) => {
|
||||
generation++; saving.value = false
|
||||
if (!visible) return
|
||||
if (pending.value && !props.trip) return
|
||||
const trip = props.trip
|
||||
pending.value = null; error.value = null
|
||||
Object.assign(form, { warehouseId: trip?.warehouseId || '', plannedDepartureAt: trip?.plannedDepartureAt || new Date(Date.now() + 3600000).toISOString(),
|
||||
expectedDriverEndAt: trip?.expectedDriverEndAt || new Date(Date.now() + 86400000).toISOString(), remark: trip?.remark || '', reason: '' })
|
||||
stops.value = trip ? trip.stops.map(stop => ({ ...stop })) : [{ name: '起运仓' }, { name: '待确定卸货点' }]
|
||||
if (!trip) void loadWarehouses()
|
||||
})
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
if (!form.warehouseId || !form.plannedDepartureAt || !form.expectedDriverEndAt || stops.value.some(stop => !stop.name.trim())) {
|
||||
ElMessage.warning('请填写起运仓、计划时间和站点名称'); return
|
||||
}
|
||||
const current = generation, trip = props.trip
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
const plan = { plannedDepartureAt: new Date(form.plannedDepartureAt).toISOString(), expectedDriverEndAt: new Date(form.expectedDriverEndAt).toISOString(),
|
||||
stops: stops.value.map(stop => ({ ...stop, name: stop.name.trim() })), remark: form.remark.trim() || null }
|
||||
if (!trip) pending.value ||= { ...plan, warehouseId: form.warehouseId, idempotencyKey: crypto.randomUUID() }
|
||||
const result = trip ? await updateTripPlan(trip.tripNo, { ...plan, recordVersion: trip.recordVersion, reason: form.reason.trim() || '更新车次计划' })
|
||||
: await createTrip(pending.value!)
|
||||
if (current !== generation || !open.value) return
|
||||
pending.value = null; emit('saved', result); open.value = false; ElMessage.success('车次计划已保存')
|
||||
} catch (cause) {
|
||||
if (current !== generation) return
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null
|
||||
error.value = getApiErrorDetails(cause, '计划保存失败')
|
||||
} finally { if (current === generation) saving.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="trip ? '编辑车次计划' : '新建车次'" width="1080px" confirm-text="保存计划" :confirm-loading="saving" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="计划保存失败" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="warehouseError && !trip" title="起运仓加载失败" :message="warehouseError.message" @retry="loadWarehouses" />
|
||||
<p v-if="pending">上次保存结果尚未确定,再次保存会核对原请求。</p>
|
||||
<el-form label-position="top" class="density-form trip-plan-form" :disabled="saving || !!pending" @submit.prevent="save">
|
||||
<el-form-item label="起运仓" required><el-input v-if="trip" :model-value="trip.warehouseName" readonly /><el-select v-else v-model="form.warehouseId" filterable placeholder="选择海外起运仓"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :value="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" /></el-select></el-form-item>
|
||||
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.plannedDepartureAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
|
||||
<div class="full-width"><h3>路线站点</h3><p>先建立计划,关联货物后可在详情中匹配实际卸货站。</p>
|
||||
<div v-for="(stop, index) in stops" :key="index" class="trip-stop-row"><span>{{ index === 0 ? '起点' : `第 ${index} 站` }}</span><el-input v-model="stop.name" placeholder="站点名称" /><el-input v-model="stop.address" placeholder="地址(选填)" /><el-button :disabled="index === 0 || stops.length <= 2" @click="stops.splice(index, 1)">移除</el-button></div>
|
||||
<el-button @click="stops.push({ name: '' })">添加站点</el-button>
|
||||
</div>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="补充本次运输安排(选填)" /></el-form-item>
|
||||
<el-form-item v-if="trip" label="调整说明(选填)" class="full-width"><el-input v-model="form.reason" placeholder="如改期或调整路线(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.trip-plan-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}
|
||||
.full-width{grid-column:1/-1}.trip-stop-row{display:grid;grid-template-columns:70px 1fr 2fr auto;gap:var(--admin-space-4);align-items:center;margin-bottom:var(--admin-space-4)}
|
||||
.trip-plan-form :deep(.el-select),.trip-plan-form :deep(.el-date-editor){width:100%}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user