+
-
+
+
+
+
+ {{ title }}
+
{
@search="search"
/>
-
-
+
-
-
+
+
-
-
- 查看
-
-
+
+ 查看
+
{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}
@@ -230,8 +231,7 @@ onMounted(() => {
{{ formatNumber((row as ConfirmedSummary).totalNetWeightKg) }}
{{ formatNumber(row.totalVolumeCbm) }}
{{ formatDateTime(row.createdAt) }}
-
-
+
diff --git a/src/modules/loading-lists/routes.ts b/src/modules/loading-lists/routes.ts
index 9f836197..b8bd6646 100644
--- a/src/modules/loading-lists/routes.ts
+++ b/src/modules/loading-lists/routes.ts
@@ -5,7 +5,7 @@ export const loadingListRoutes = [
path: 'loading-plans',
name: 'loading-plans',
component: () => import('./pages/PreloadPlansView.vue'),
- meta: {
+ meta: { showPageHeading: false,
title: '预装清单',
description: '查看全部仓库拣货中的预装计划、货物明细和操作历史',
permission: 'platform:firstleg:preload:list',
diff --git a/src/modules/master-data/counterparties/pages/PartnersView.test.ts b/src/modules/master-data/counterparties/pages/PartnersView.test.ts
index aaac30ce..9748a8d6 100644
--- a/src/modules/master-data/counterparties/pages/PartnersView.test.ts
+++ b/src/modules/master-data/counterparties/pages/PartnersView.test.ts
@@ -139,12 +139,12 @@ describe('PartnersView real API integration', () => {
expect(wrapper.text()).not.toContain('往来单位档案')
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.find('.data-table').exists()).toBe(true)
- expect(wrapper.find('.table-panel').exists()).toBe(true)
+ expect(wrapper.find('.el-table').exists()).toBe(true)
expect(wrapper.find('.query-toolbar__actions').text()).not.toContain('新增往来单位')
expect(wrapper.find('.data-table__business-actions').text()).toContain('新增往来单位')
- expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false)
+ expect(wrapper.get('.data-table__toolbar-start h1').text()).toBe('往来单位')
expect(wrapper.find('.data-table__pagination-left').text()).toContain('平台公共数据 · 共 1 个往来单位')
- expect(wrapper.find('.list-page-layout__state').exists()).toBe(false)
+ expect(wrapper.find('.data-table__notices').exists()).toBe(false)
const detailButton = wrapper.findAll('button').find((button) => button.text().trim() === '详情')
await detailButton?.trigger('click')
await flushPromises()
@@ -248,8 +248,8 @@ describe('PartnersView real API integration', () => {
expect(wrapper.text()).not.toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新加载')
expect(wrapper.text()).not.toContain('深圳示例贸易有限公司')
- expect(wrapper.find('.list-page-layout__state').exists()).toBe(true)
- expect(wrapper.find('.table-panel').exists()).toBe(false)
+ expect(wrapper.find('.data-table__notices').exists()).toBe(true)
+ expect(wrapper.find('.el-table').exists()).toBe(false)
})
it('keeps the latest detail when an older partner request finishes last', async () => {
diff --git a/src/modules/master-data/counterparties/pages/PartnersView.vue b/src/modules/master-data/counterparties/pages/PartnersView.vue
index 274c6676..94b99b92 100644
--- a/src/modules/master-data/counterparties/pages/PartnersView.vue
+++ b/src/modules/master-data/counterparties/pages/PartnersView.vue
@@ -373,23 +373,11 @@ onMounted(() => {
-
-
-
-
-
-
-
-
-
- {
row-key="id"
empty-text="暂无符合条件的往来单位"
>
+ 往来单位
+
+
+
+
+
+
+
+
+
+
-
-
+
详情
编辑
{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}
-
-
+
{{ partnerTypeLabel(type) }}
{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}
{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '启用' : '停用' }}
平台公共数据 · 共 {{ total }} 个往来单位
-
-
+
{
expect(wrapper.text()).toContain('顺丰速运')
expect(wrapper.text()).toContain('平台公共数据 · 共 1 家快递公司')
expect(wrapper.find('.metric-strip').exists()).toBe(false)
- expect(wrapper.find('.list-page-layout__state').exists()).toBe(false)
+ expect(wrapper.find('.data-table__notices').exists()).toBe(false)
expect(listPlatformExpressCompanies).toHaveBeenCalledWith(1, 20, '', '')
expect(wrapper.find('.data-table').text()).not.toContain('轨迹查询模板')
expect(wrapper.find('.data-table').text()).not.toContain('测试数据')
@@ -121,8 +121,8 @@ describe('ExpressCompaniesView query and table migration', () => {
vi.mocked(listPlatformExpressCompanies).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView()
- expect(wrapper.find('.list-page-layout__state').exists()).toBe(true)
- expect(wrapper.find('.table-panel').exists()).toBe(false)
+ expect(wrapper.find('.data-table__notices').exists()).toBe(true)
+ expect(wrapper.find('.el-table').exists()).toBe(false)
expect(wrapper.text()).toContain('快递公司加载失败')
expect(wrapper.text()).not.toContain('Gateway unavailable')
})
diff --git a/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue b/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue
index c462c6ab..42adbe8d 100644
--- a/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue
+++ b/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue
@@ -284,20 +284,10 @@ onMounted(() => {
-
-
-
-
-
- 快递公司加载失败
- {{ loadError.message }}
-
- 重新查询
-
-
-
- {
row-key="id"
:empty-text="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'"
>
+ 快递公司
+
+
+
+
+
+ 快递公司加载失败
+ {{ loadError.message }}
+
+ 重新查询
+
+
+
+
-
+
{
>{{ (row as PlatformExpressCompanyResource).status === 'ACTIVE' ? '停用' : '启用' }}
仅查看
-
+
{{ (row as PlatformExpressCompanyResource).companyName }}
访问官网
@@ -346,8 +350,7 @@ onMounted(() => {
平台公共数据 · 共 {{ tableTotal }} 家快递公司
-
-
+
import('@/modules/master-data/express-companies/pages/ExpressCompaniesView.vue'),
- meta: { title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' },
+ meta: { showPageHeading: false, title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' },
},
{
path: 'master-data/counterparties',
name: 'counterparties',
component: () => import('@/modules/master-data/counterparties/pages/PartnersView.vue'),
- meta: { title: '往来单位', description: '维护客户、供应商及客户/供应商双重类型单位档案', permission: 'platform:counterparty:list' },
+ meta: { showPageHeading: false, title: '往来单位', description: '维护客户、供应商及客户/供应商双重类型单位档案', permission: 'platform:counterparty:list' },
},
] satisfies RouteRecordRaw[]
diff --git a/src/modules/notifications/pages/MessageManagementView.vue b/src/modules/notifications/pages/MessageManagementView.vue
index 6be0596c..a2834a71 100644
--- a/src/modules/notifications/pages/MessageManagementView.vue
+++ b/src/modules/notifications/pages/MessageManagementView.vue
@@ -18,7 +18,12 @@ function search(){page.value===1?void load():page.value=1}
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',type==='cancel'?'取消任务':'重试任务',{type:'warning',customClass:'admin-density-confirm'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}}
watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
- 新建消息共 {{ result.total }} 个任务 查看 取消 重试
{{notificationCategoryLabel((row as NotificationTask).category)}} {{formatDate((row as NotificationTask).scheduledAt)}} {{formatDate((row as NotificationTask).sentAt)}} {{statusLabel((row as NotificationTask).status)}}
任务号 {{detail.taskNo}}
状态 {{statusLabel(detail.status)}}
业务分类 {{notificationCategoryLabel(detail.category)}}
接收人数 {{detail.recipientCount}}
尝试次数 {{detail.attemptCount}}
发送时间 {{formatDate(detail.sentAt)}} 任务失败原因 {{detail.lastError}}
收件人投递明细 {{notificationAppLabel(row.appCode)}} {{notificationRecipientStatusLabel(row.status)}} {{formatDate(row.deliveredAt)}} {{row.failureReason||'—'}}
+
+ 消息管理
+
+
+
+ 新建消息共 {{ result.total }} 个任务 查看 取消 重试
{{notificationCategoryLabel((row as NotificationTask).category)}} {{formatDate((row as NotificationTask).scheduledAt)}} {{formatDate((row as NotificationTask).sentAt)}} {{statusLabel((row as NotificationTask).status)}}
任务号 {{detail.taskNo}}
状态 {{statusLabel(detail.status)}}
业务分类 {{notificationCategoryLabel(detail.category)}}
接收人数 {{detail.recipientCount}}
尝试次数 {{detail.attemptCount}}
发送时间 {{formatDate(detail.sentAt)}} 任务失败原因 {{detail.lastError}}
收件人投递明细 {{notificationAppLabel(row.appCode)}} {{notificationRecipientStatusLabel(row.status)}} {{formatDate(row.deliveredAt)}} {{row.failureReason||'—'}}
diff --git a/src/modules/overseas-master-data/components/DriverLicensePreview.vue b/src/modules/overseas-master-data/components/DriverLicensePreview.vue
new file mode 100644
index 00000000..b27bcfd5
--- /dev/null
+++ b/src/modules/overseas-master-data/components/DriverLicensePreview.vue
@@ -0,0 +1,47 @@
+
+
+
+
+ {{ driverName }} · 驾驶证附件
+
+
+
+
+
+ 下载驾驶证
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/FleetQuoteEditor.test.ts b/src/modules/overseas-master-data/components/FleetQuoteEditor.test.ts
new file mode 100644
index 00000000..175df81a
--- /dev/null
+++ b/src/modules/overseas-master-data/components/FleetQuoteEditor.test.ts
@@ -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()), createFleetQuote: vi.fn(), updateFleetQuote: vi.fn() }))
+const wrappers: VueWrapper[] = []
+const input = (name: string) => ({ name, props: ['modelValue'], emits: ['update:modelValue'], template: ' ' })
+const slot = { template: '
' }
+async function mount() {
+ const wrapper = shallowMount(FleetQuoteEditor, { props: { modelValue: false, quote: null, kind: 'SALE' }, global: { stubs: {
+ AppDialog: { props: ['modelValue'], template: '
' }, ElForm: { name: 'ElForm', props: ['disabled'], template: '
' },
+ ElFormItem: slot, ElInput: input('ElInput'), ElInputNumber: input('ElInputNumber'), ElDatePicker: input('ElDatePicker'), ElSelect: slot, ElOption: true,
+ FleetQuotePartySelect: input('FleetQuotePartySelect'), ElButton: { template: ' ' }, ElAlert: { props: ['title'], template: '{{ title }}
' }, 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)
+})
diff --git a/src/modules/overseas-master-data/components/FleetQuoteEditor.vue b/src/modules/overseas-master-data/components/FleetQuoteEditor.vue
new file mode 100644
index 00000000..7aaac54a
--- /dev/null
+++ b/src/modules/overseas-master-data/components/FleetQuoteEditor.vue
@@ -0,0 +1,57 @@
+
+
+
+ 保存结果尚未确认,重试将发送原请求和原内容。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。
+ 关闭 {{ uncertain ? '按原请求重试' : '保存报价' }}
+
+
diff --git a/src/modules/overseas-master-data/components/FleetQuoteImport.test.ts b/src/modules/overseas-master-data/components/FleetQuoteImport.test.ts
new file mode 100644
index 00000000..580b750d
--- /dev/null
+++ b/src/modules/overseas-master-data/components/FleetQuoteImport.test.ts
@@ -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()), importFleetQuotes: vi.fn() }))
+const wrappers: VueWrapper[] = []
+async function mount() {
+ const wrapper = shallowMount(FleetQuoteImport, { props: { modelValue: true, kind: 'SALE' }, global: { stubs: {
+ AppDialog: { template: '
' },
+ FleetQuotePartySelect: { name: 'FleetQuotePartySelect', props: ['modelValue', 'kind', 'disabled'], emits: ['update:modelValue'], template: '
' },
+ ElButton: { props: ['disabled', 'loading'], template: ' ' },
+ ElAlert: { props: ['title'], template: '{{ title }}
' }, 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) {
+ 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(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')
+})
diff --git a/src/modules/overseas-master-data/components/FleetQuoteImport.vue b/src/modules/overseas-master-data/components/FleetQuoteImport.vue
new file mode 100644
index 00000000..10a3b8f4
--- /dev/null
+++ b/src/modules/overseas-master-data/components/FleetQuoteImport.vue
@@ -0,0 +1,37 @@
+
+
+ 本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。
+
+ 下载CSV模板
+
+ 已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。
+ 共 {{ rows.length }} 条,预览前10条;目录有效性与重复组合由服务端核对。
+ 发货城市 目的仓码 距离(mile) 币种 整车自提 整车派送 散板派送 有效期 {{ row.originCity }} {{ row.destinationCode }} {{ row.distanceMiles }} {{ row.currency }} {{ row.selfPickupPrice ?? '—' }} {{ row.fullDeliveryPrice ?? '—' }} {{ row.looseDeliveryPrice ?? '—' }} {{ row.validFrom }} — {{ row.validTo }}
+ 关闭 {{ pending ? '按原请求重试' : '确认导入' }}
+
diff --git a/src/modules/overseas-master-data/components/FleetQuotePartySelect.vue b/src/modules/overseas-master-data/components/FleetQuotePartySelect.vue
new file mode 100644
index 00000000..7d59ea9a
--- /dev/null
+++ b/src/modules/overseas-master-data/components/FleetQuotePartySelect.vue
@@ -0,0 +1,21 @@
+
+
+
+
+ {{ error }}
选择车队需要供应商列表权限。
diff --git a/src/modules/overseas-master-data/components/TripAssignmentPanel.vue b/src/modules/overseas-master-data/components/TripAssignmentPanel.vue
new file mode 100644
index 00000000..d917198d
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripAssignmentPanel.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存分配
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts
new file mode 100644
index 00000000..cf335961
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts
@@ -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 = { 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: ' ' }
+ const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
+ AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '
' },
+ DataTable: { props: ['data'], template: '' },
+ ElForm: { template: '
' }, ElFormItem: { template: '
' }, 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()
+})
diff --git a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue
new file mode 100644
index 00000000..dbaf1838
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}
+
+
+
+
+
+
+ 来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}
+
+
+ 原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。
+
+
先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/TripBookingPanel.test.ts b/src/modules/overseas-master-data/components/TripBookingPanel.test.ts
new file mode 100644
index 00000000..e96efd5f
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripBookingPanel.test.ts
@@ -0,0 +1,138 @@
+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 = { 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: ' ' }
+ const wrapper = shallowMount(TripBookingPanel, { props: { trip }, global: { stubs: {
+ DataTable: { props: ['data'], template: '' },
+ AppDialog: { name: 'AppDialog', props: { modelValue: Boolean, showConfirm: Boolean, destroyOnClose: Boolean }, emits: ['confirm', 'update:modelValue'], template: realNumber ? '
' : '
' },
+ ElButton: { props: ['disabled'], template: ' ' },
+ ElForm: { template: '
' }, ElFormItem: { template: '
' },
+ 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('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('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('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)
+})
diff --git a/src/modules/overseas-master-data/components/TripBookingPanel.vue b/src/modules/overseas-master-data/components/TripBookingPanel.vue
new file mode 100644
index 00000000..bc17e4e5
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripBookingPanel.vue
@@ -0,0 +1,191 @@
+
+
+
+
询价不占用车辆空间;确认承运后预留容量。实收时核对运费,转派送时按确认报价登记应收,付款状态以财务为准。
+
+
+ 刷新预订 {{ pendingCreate ? '继续原登记' : '登记外接询价' }}
+ 详情 编辑 确认承运 取消预订 来货详情 登记来货 核对实收 关联排车
+ {{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}
+ {{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³
+ {{ row.incomingNo || '尚未登记来货' }}
{{ row.dispatchNo || '尚未关联排车' }}
+ {{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 来源:{{ quoteSource.quoteNo }} · 版本 {{ quoteSource.recordVersion }} · {{ quoteSource.originCity }} → {{ quoteSource.destinationCode }} · 适用日期 {{ quoteSource.effectiveOn }} 来源单价:{{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }} / {{ quoteSource.priceBasis === 'PALLET' ? '托位' : '单' }} · 有效期 {{ quoteSource.validFrom }} 至 {{ quoteSource.validTo }}
+ {{ quoteRef ? `已选报价 ${quoteRef.quoteNo} · 版本 ${quoteRef.recordVersion}` : '手工询价,尚未选择报价库来源。' }}
+
+ 查询报价库
+ 改为手工询价
+ 选择客户、FBA卸货站及CNY或USD后可查询;其他场景可继续填写手工价格。
+
+
+
+ 暂无符合本车路线、客户、日期和币种的报价,可继续手工询价。
+ 上一页 {{ quotePage }} / {{ quotePages }} 下一页
+ {{ quoteError.message }}
+
+ 来源报价与实际约定价格分别保存;确认承运时会重新核对报价版本与适用日期。
+
+ 约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/TripCostPanel.test.ts b/src/modules/overseas-master-data/components/TripCostPanel.test.ts
new file mode 100644
index 00000000..ab3f0000
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripCostPanel.test.ts
@@ -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: ' ' }
+function mount() {
+ const wrapper = shallowMount(TripCostPanel, { props: { tripNo: 'TR-ONE' }, global: { stubs: {
+ ElButton: { template: ' ' }, ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true,
+ ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input,
+ AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], emits: ['confirm'], template: '保存
' },
+ DataTable: { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '' },
+ RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '{{ a.label }}
' },
+ LoadErrorState: { props: ['message'], template: '{{ message }}
' }, 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('尚无实际发车装载记录')
+})
diff --git a/src/modules/overseas-master-data/components/TripCostPanel.vue b/src/modules/overseas-master-data/components/TripCostPanel.vue
new file mode 100644
index 00000000..d60586de
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripCostPanel.vue
@@ -0,0 +1,189 @@
+
+
+
+
+ 暂无车次成本查看权限。
+
+ 按实际费用登记,金额按币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。
+
+
+ 尚未登记直接成本。
+ {{ summary.allocationBlockReason }}
+
+ {{ value.currency }} · 已登记直接成本 {{ value.directCost.toFixed(2) }}
+ 自有货分摊 {{ money(value.ownCost) }} · 外接货分摊 {{ money(value.externalCost) }}
+
+ {{ row.sourceType === 'EXTERNAL' ? '外接货' : '自有业务货物' }}
+ {{ value.currency }} {{ row.amount.toFixed(2) }}
+
+
+
+
+ 刷新 登记成本
+
+ {{ categories[row.details.category as CostCategory] }}
+ {{ row.details.currency }} {{ row.details.amount.toFixed(2) }}
+ {{ time(row.details.incurredAt) }}
+ {{ row.details.remark || '—' }}
+
+
+
+
+ 正在核对上次登记结果,重试会使用同一请求,不重复记账。
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ time(row.occurredAt) }}
+ {{ row.action === 'CREATE' ? '登记' : '更正' }}
+ {{ row.snapshot.cost.details.currency }} {{ row.snapshot.cost.details.amount.toFixed(2) }}
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/TripHandoverPanel.test.ts b/src/modules/overseas-master-data/components/TripHandoverPanel.test.ts
new file mode 100644
index 00000000..9735612f
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripHandoverPanel.test.ts
@@ -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()), 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: ' ' }
+function mount() {
+ const wrapper = shallowMount(TripHandoverPanel, { props: { detail: structuredClone(detail) }, global: { stubs: {
+ ElButton: { template: ' ' }, ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true,
+ ElInput: { ...input, name: 'ElInput' }, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: input,
+ DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '' },
+ RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '{{ a.label }}
' },
+ LoadErrorState: { props: ['message'], template: '{{ message }}
' }, 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>) => 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)
+})
diff --git a/src/modules/overseas-master-data/components/TripHandoverPanel.vue b/src/modules/overseas-master-data/components/TripHandoverPanel.vue
new file mode 100644
index 00000000..78c70060
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripHandoverPanel.vue
@@ -0,0 +1,200 @@
+
+
+
+
+ 暂无车次交接查看权限。
+
+ 交接后原车辆和司机分别结束实际任务。已经签收或完成退回的货物保留原运输记录。
+
+
+
上次交接结果尚待确认;重试使用原请求及原内容。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
核对交接货物
+
刷新车次
+
+ 驾驶证将在30天内到期,当前预计任务结束仍在有效期内。
+ 本次交接 {{ preview.cargo.remainingPalletIds.length }} 个实托,占 {{ handoverNumber(preview.cargo.occupied.palletSpaces) }} 托位、{{ handoverNumber(preview.cargo.occupied.weightKg) }} kg、{{ handoverNumber(preview.cargo.occupied.volumeCbm) }} m³。
+ 客户排车单 交接箱数 {{ cargo.dispatchNo }} {{ cargo.packages }}
+
+
{{ pending ? '重试原交接请求' : '确认实际交接' }}
+
+ {{ commandError.message }}
+
+
+ {{ time(row.snapshot.handedOverAt) }}
+ {{ row.snapshot.previous.vehiclePlate || '—' }} → {{ row.snapshot.replacement.vehiclePlate || '—' }}
+ {{ row.snapshot.previous.driverName || '—' }} → {{ row.snapshot.replacement.driverName || '—' }}
+ {{ row.snapshot.location }}
+
+
+
交接记录 {{ selected.handoverNo }}
+
{{ selected.snapshot.reason }} · {{ selected.snapshot.location }}
+
原车辆任务:{{ selected.snapshot.previous.vehicleId === selected.snapshot.replacement.vehicleId ? '车辆继续执行' : selected.vehicleRelease ? `已结束 · ${time(selected.vehicleRelease.actualEndAt)} · ${selected.vehicleRelease.location}` : '尚未结束' }}
+
原司机任务:{{ selected.snapshot.previous.driverId === selected.snapshot.replacement.driverId ? '司机继续执行' : selected.driverRelease ? `已结束 · ${time(selected.driverRelease.actualEndAt)} · ${selected.driverRelease.location}` : '尚未结束' }}
+
交接前已完成派送:{{ selected.snapshot.cargo.completedDeliveries.map(row => row.deliveryNo).join('、') || '—' }}
+
+ 上次任务结束结果尚待确认;重试不会释放后续车次占用。
+
+
+
+
+
+ {{ pendingRelease ? '重试原任务结束请求' : '确认原资源任务结束' }}
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/TripLoadingPanel.vue b/src/modules/overseas-master-data/components/TripLoadingPanel.vue
new file mode 100644
index 00000000..89cf1d2d
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripLoadingPanel.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
先核对本车自有货的预计载荷,查看每段剩余托位、载重和容积。实际装载完成后仍需按实测值确认。
+
货物明细已变化,请重新核对计划容量。
+
{{ info.blockReason }}
+
已确认实际装载;车辆、司机或计划调整后需要重新确认。
+
按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。
+
+
+
+
+
+ {{ planning ? '重新读取容量计划' : '重新读取装载' }} {{ planning ? '确认计划容量' : '确认实际装载' }}
+
+
行驶区间 占用托位 重量(kg) 体积(m³) 剩余托位 / kg / m³ {{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }} {{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }} {{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }} {{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }} {{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}
+
+
diff --git a/src/modules/overseas-master-data/components/TripOperationsCharts.vue b/src/modules/overseas-master-data/components/TripOperationsCharts.vue
new file mode 100644
index 00000000..ce61a1af
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripOperationsCharts.vue
@@ -0,0 +1,27 @@
+
+
+ {{ section.title }} {{ section.note }}
+ 暂无对应统计
+ {{ series.label }}({{ series.unit }})
+
{{ point.label }} {{ point.value }}
+
+
+
diff --git a/src/modules/overseas-master-data/components/TripOperationsFinanceCell.test.ts b/src/modules/overseas-master-data/components/TripOperationsFinanceCell.test.ts
new file mode 100644
index 00000000..d4fd7000
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripOperationsFinanceCell.test.ts
@@ -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: ' ' }, ElPopover: { template: '
' }, 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('实收 无读取权限')
+})
diff --git a/src/modules/overseas-master-data/components/TripOperationsFinanceCell.vue b/src/modules/overseas-master-data/components/TripOperationsFinanceCell.vue
new file mode 100644
index 00000000..927a0c26
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripOperationsFinanceCell.vue
@@ -0,0 +1,40 @@
+
+
+ 无应收读取权限
+
+ {{ error ? '重试财务读取' : '查看应收/实收' }}
+ {{ error.message }}
+
+ {{ m.currency }} 应收 {{ m.receivableAmount.toFixed(2) }} / 实收 {{ !result.receiptsVisible ? '无读取权限' : m.receivedAmount == null ? '待分配' : m.receivedAmount.toFixed(2) }}
+ 完整金额待分配
+
+ {{ result.attributionReason }}
{{ result.finance.reason }}
+
排车来源:{{ result.dispatchNos.join(' / ') || '—' }}
+
{{ s.businessNo }} · {{ s.sourceNo || '—' }} · {{ s.feeItemName }} · 版本 {{ s.sourceVersion }} · {{ s.currency }} {{ s.amount.toFixed(2) }}
+
{{ d.billNo }} · {{ d.currency }} {{ d.receivedAmount == null ? '待分配' : d.receivedAmount.toFixed(2) }} · {{ d.reason }}
+
金额来源
+
+ 刷新
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts
new file mode 100644
index 00000000..107c22a7
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts
@@ -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: '
' }
+const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: ' ' }
+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: '
' }, ElForm: realControls ? false : slot, ElFormItem: realControls ? false : slot,
+ ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '
' }, ElTabPane: slot, ElInputNumber: realControls ? false : input,
+ ElInput: realControls ? false : { ...input, name: 'ElInput' }, ElButton: { props: ['disabled', 'loading'], template: ' ' },
+ ElAlert: { props: ['title'], template: '{{ title }}
' }, 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()
+})
diff --git a/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue
new file mode 100644
index 00000000..1020ee8f
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue
@@ -0,0 +1,106 @@
+
+
+ 重新读取
+ {{ row.tripNo }} · {{ row.vehiclePlate || '—' }} · 实际发车 {{ row.departedAt }} · 货物结案 {{ row.completedAt || '—' }}
+
+ 上次保存结果未确认,将按原请求重试。
+
+
+
+
+
+
+
+ PDF、JPG、PNG,单个不超过10MiB,最多20个;不上传也可保存。
+
+
+ {{ file.fileName }} (保存后可预览) 移除引用
+ 正在上传 {{ uploadName }}…
+ {{ uploadName }} 的上传结果未确认,可能已上传但尚未绑定。请先核对,不要重复上传。暂不绑定此附件
+ 未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。
+ {{ item.details.actualDistanceMiles ?? '—' }} {{ item.details.actualFuelLitres ?? '—' }} {{ item.details.remark || '—' }} {{ file.fileName }} —
+ 关闭预览
+ 关闭 {{ uncertain ? '按原请求重试' : '保存记录' }}
+
+
diff --git a/src/modules/overseas-master-data/components/TripPlanDialog.vue b/src/modules/overseas-master-data/components/TripPlanDialog.vue
new file mode 100644
index 00000000..e2f5ec9b
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripPlanDialog.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+ 上次保存结果尚未确定,再次保存会核对原请求。
+
+
+
+
+ 路线站点 先建立计划,关联货物后可在详情中匹配实际卸货站。
+
{{ index === 0 ? '起点' : `第 ${index} 站` }} 移除
+
添加站点
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/VehicleCareBehavior.test.ts b/src/modules/overseas-master-data/components/VehicleCareBehavior.test.ts
new file mode 100644
index 00000000..e35e4174
--- /dev/null
+++ b/src/modules/overseas-master-data/components/VehicleCareBehavior.test.ts
@@ -0,0 +1,180 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
+import { ApiResponseError } from '@/shared/api/http'
+import VehicleCareEditor from './VehicleCareEditor.vue'
+import VehicleCareTable from './VehicleCareTable.vue'
+import VehicleDetailView from '../pages/VehicleDetailView.vue'
+import * as api from '../api/vehicleCare'
+import { getVehicle } from '../api/overseasMasterData'
+import type { VehicleCareRecord } from '../vehicleCareTypes'
+const access = vi.hoisted(() => ({ write: true }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || access.write }) }))
+vi.mock('vue-router', () => ({ useRoute: () => ({ params: { vehicleId: '71' } }), useRouter: () => ({ push: vi.fn() }) }))
+vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } }))
+vi.mock('../api/vehicleCare', async original => ({ ...(await original()), listVehicleCare: vi.fn(), getVehicleCare: vi.fn(), createVehicleCare: vi.fn(), updateVehicleCare: vi.fn(), uploadVehicleCarePhoto: vi.fn(), downloadVehicleCarePhoto: vi.fn(), listVehicleCareFinance: vi.fn(), submitVehicleCareFinance: vi.fn(), listVehicleCareSuppliers: vi.fn() }))
+vi.mock('../api/overseasMasterData', () => ({ getVehicle: vi.fn() }))
+const row: VehicleCareRecord = { recordNo: 'VR-ONE', vehicleId: '71', vehiclePlate: 'VEH-A', recordVersion: 0, details: { 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 }, files: [], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'operator', paymentStatus: null }
+const container = { template: '
' }
+const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: ' ' }
+const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'columns'], emits: ['update:page'], template: '' }
+const dialog = { name: 'AppDialog', props: ['modelValue', 'showConfirm'], emits: ['confirm'], template: '保存
' }
+const wrappers: VueWrapper[] = []
+function mount(component: object, props: Record): VueWrapper {
+ const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
+ AdminPage: container, DetailPageLayout: container, FormSection: container, AppDialog: dialog, LoadErrorState: true, ApiErrorMeta: true,
+ ElButton: { template: ' ' }, ElForm: container, ElFormItem: container,
+ ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: { ...input, name: 'ElDatePicker' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElOption: true,
+ ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '
' }, ElTabPane: true, DataTable: table, RowActions: true, VehicleCareTable: component === VehicleCareTable ? false : true, VehicleCareEditor: component === VehicleCareEditor ? false : true,
+ } } }); wrappers.push(wrapper); return wrapper
+}
+beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [structuredClone(row)], page: 1, size: 10, total: 1, pages: 1 })
+ vi.mocked(api.listVehicleCareFinance).mockImplementation(async (_vehicle, numbers) => numbers.map(recordNo => ({ recordNo, status: 'UNLINKED', sourceVersion: null, submittedRecordVersion: null, finance: null })))
+ vi.mocked(api.listVehicleCareSuppliers).mockResolvedValue([])
+})
+afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
+it('saving an unchanged record does not create a correction due to JSON property order', async () => {
+ const record = structuredClone(row)
+ record.details = Object.fromEntries(Object.entries(record.details).reverse()) as typeof record.details
+ const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record })
+ await wrapper.setProps({ modelValue: true })
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.updateVehicleCare).not.toHaveBeenCalled()
+ expect(wrapper.emitted('saved')).toHaveLength(1)
+})
+it('loads care only after the detail tab is selected', async () => {
+ vi.mocked(getVehicle).mockResolvedValue({ id: '71', plateNumber: 'VEH-A', model: 'Truck', fuelConsumptionPer100Km: 10, mileageKm: 1000, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', recordVersion: 0 } as Awaited>)
+ const wrapper = mount(VehicleDetailView, {}); await flushPromises()
+ expect(wrapper.findAllComponents({ name: 'VehicleCareTable' })).toHaveLength(0)
+ expect(api.listVehicleCare).not.toHaveBeenCalled()
+ wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'care'); await flushPromises()
+ expect(wrapper.findAllComponents({ name: 'VehicleCareTable' })).toHaveLength(2)
+})
+it('requests one page at a time, never preloads photos, and ignores the old vehicle response', async () => {
+ let resolveOld!: (value: Awaited>) => void
+ vi.mocked(api.listVehicleCare).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
+ const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' })
+ await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B' }); await flushPromises()
+ expect(api.listVehicleCare).toHaveBeenCalledTimes(2)
+ resolveOld({ records: [{ ...row, recordNo: 'OLD' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')[0].recordNo).toBe('VR-ONE')
+ expect(api.downloadVehicleCarePhoto).not.toHaveBeenCalled()
+})
+it('retains the saved record and exact photo key after an upload failure', async () => {
+ vi.mocked(api.createVehicleCare).mockResolvedValue(structuredClone(row))
+ vi.mocked(api.uploadVehicleCarePhoto).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ ...row, recordVersion: 1 })
+ const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
+ await wrapper.setProps({ modelValue: true })
+ wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
+ const file = new File(['photo'], 'meter.png', { type: 'image/png' }), picker = wrapper.get('input[type="file"]')
+ Object.defineProperty(picker.element, 'files', { value: [file] }); await picker.trigger('change')
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('记录已保存')
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.createVehicleCare).toHaveBeenCalledTimes(1); expect(api.updateVehicleCare).not.toHaveBeenCalled()
+ expect(api.uploadVehicleCarePhoto).toHaveBeenCalledTimes(2)
+ expect(vi.mocked(api.uploadVehicleCarePhoto).mock.calls[0]).toEqual(vi.mocked(api.uploadVehicleCarePhoto).mock.calls[1])
+})
+it('keeps read-only users out of create edit and upload controls', async () => {
+ access.write = false
+ const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' }); await flushPromises()
+ expect(wrapper.text()).not.toContain('添加加油记录')
+ expect(wrapper.findComponent({ name: 'RowActions' }).props('actions').every((action: { visible: boolean }) => !action.visible)).toBe(true)
+})
+it('reads the latest record only when editing instead of reusing an old page version', async () => {
+ vi.mocked(api.getVehicleCare).mockResolvedValue({ ...row, recordVersion: 5 })
+ const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' }); await flushPromises()
+ expect(api.getVehicleCare).not.toHaveBeenCalled()
+ wrapper.findComponent({ name: 'RowActions' }).vm.$emit('action', 'edit'); await flushPromises()
+ expect(api.getVehicleCare).toHaveBeenCalledExactlyOnceWith('71', 'VR-ONE')
+ expect(wrapper.findComponent({ name: 'VehicleCareEditor' }).props('record').recordVersion).toBe(5)
+})
+it('allows correction after a definitive validation failure instead of locking the invalid draft', async () => {
+ vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new ApiResponseError('请填写加油量', 'A0400', 200)).mockResolvedValueOnce(structuredClone(row))
+ const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
+ await wrapper.setProps({ modelValue: true }); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(wrapper.text()).not.toContain('正在核对原保存结果')
+ wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.createVehicleCare).toHaveBeenLastCalledWith('71', expect.any(String), expect.objectContaining({ fuelQuantity: 28.6 }))
+})
+
+it('does not continue uploading vehicle photos after the editor is unmounted', async () => {
+ let finish!: (value: VehicleCareRecord) => void
+ vi.mocked(api.createVehicleCare).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
+ const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
+ await wrapper.setProps({ modelValue: true })
+ wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
+ const picker = wrapper.get('input[type="file"]')
+ Object.defineProperty(picker.element, 'files', { value: [new File(['png'], 'fuel.png', { type: 'image/png' })] })
+ await picker.trigger('change')
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ wrapper.unmount(); finish(structuredClone(row)); await flushPromises()
+ expect(api.uploadVehicleCarePhoto).not.toHaveBeenCalled()
+ expect(wrapper.emitted('saved')).toBeUndefined()
+})
+
+it('keeps an uncertain create key scoped to its vehicle when the editor is reused', async () => {
+ vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new Error('unknown network outcome')).mockResolvedValueOnce({ ...row, vehicleId: '72', recordNo: 'VR-TWO' })
+ const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
+ await wrapper.setProps({ modelValue: true })
+ wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ const oldKey = vi.mocked(api.createVehicleCare).mock.calls[0]![1]
+ expect(wrapper.text()).toContain('正在核对原保存结果')
+ await wrapper.setProps({ modelValue: false })
+ await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B', modelValue: true }); await flushPromises()
+ expect(wrapper.text()).not.toContain('正在核对原保存结果')
+ wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 12)
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.createVehicleCare).toHaveBeenLastCalledWith('72', expect.any(String), expect.objectContaining({ fuelQuantity: 12 }))
+ expect(vi.mocked(api.createVehicleCare).mock.calls[1]![1]).not.toBe(oldKey)
+})
+
+it('saves repair project and place separately without submitting hidden mileage or fuel fields', async () => {
+ vi.mocked(api.createVehicleCare).mockImplementation(async (vehicleId, _key, details) => ({ ...row, vehicleId, details, paymentStatus: 'UNLINKED' }))
+ const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'REPAIR', record: null })
+ await wrapper.setProps({ modelValue: true })
+ await wrapper.get('input[placeholder="请输入具体项目"]').setValue('更换轮胎')
+ await wrapper.get('input[placeholder="请输入实际地点"]').setValue('维修站')
+ await wrapper.get('input[placeholder="请说明事件及实际处理情况"]').setValue('轮胎损坏,现场维修')
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.createVehicleCare).toHaveBeenCalledWith('71', expect.any(String), expect.objectContaining({ recordType: 'REPAIR', odometer: null, distanceUnit: null, verificationStatus: null, fuelQuantity: null,
+ incident: expect.objectContaining({ item: '更换轮胎', place: '维修站', description: '轮胎损坏,现场维修', reportedCost: null, currency: null }) }))
+ expect(wrapper.find('input[type="file"]').exists()).toBe(false)
+})
+
+it('renders completed rescue and zero reported cost separately from payment and provides the approved update action', async () => {
+ const rescue: VehicleCareRecord = { ...row, paymentStatus: 'UNLINKED', details: { ...row.details, recordType: 'RESCUE', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null,
+ incident: { item: '拖车', place: '现场', description: '处理完成', handler: '救援人员', processingStatus: 'COMPLETED', exceptionType: null, reportedCost: 0, currency: 'USD' } } }
+ vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [rescue], page: 1, size: 10, total: 1, pages: 1 })
+ const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'RESCUE' }); await flushPromises()
+ expect(wrapper.get('[data-test="status"]').text()).toBe('已完成')
+ expect(wrapper.get('[data-test="payment"]').text()).toBe('未提交财务')
+ expect(wrapper.get('[data-test="cost"]').text()).toBe('USD 0.00')
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')[0].key).toBe('actions')
+ expect(wrapper.findComponent({ name: 'RowActions' }).props('actions')[0].label).toBe('更新处理')
+})
+
+it('editing an exception does not mutate the displayed source until a command succeeds', async () => {
+ const exception: VehicleCareRecord = { ...row, details: { ...row.details, recordType: 'EXCEPTION', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null,
+ incident: { item: null, place: null, description: '原异常描述', handler: null, processingStatus: 'PENDING', exceptionType: 'TIMEOUT', reportedCost: null, currency: null } } }
+ const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'EXCEPTION', record: exception })
+ await wrapper.setProps({ modelValue: true })
+ await wrapper.get('input[placeholder="请说明事件及实际处理情况"]').setValue('尚未保存的修改')
+ expect(exception.details.incident?.description).toBe('原异常描述')
+ expect(api.updateVehicleCare).not.toHaveBeenCalled()
+})
+
+it('does not let an older finance batch overwrite the status just read for the opened record', async () => {
+ const repair: VehicleCareRecord = { ...row, details: { ...row.details, recordType: 'REPAIR' } }
+ vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [repair], page: 1, size: 10, total: 1, pages: 1 })
+ vi.mocked(api.getVehicleCare).mockResolvedValue(repair)
+ let finish!: (value: Awaited>) => void
+ vi.mocked(api.listVehicleCareFinance).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
+ .mockResolvedValueOnce([{ recordNo: row.recordNo, status: 'PENDING_FINANCE', sourceVersion: '1', submittedRecordVersion: '1', finance: null }])
+ const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'REPAIR' }); await flushPromises()
+ wrapper.findComponent({ name: 'RowActions' }).vm.$emit('action', 'finance'); await flushPromises()
+ finish([{ recordNo: row.recordNo, status: 'UNLINKED', sourceVersion: null, submittedRecordVersion: null, finance: null }]); await flushPromises()
+ expect(wrapper.get('[data-test="payment"]').text()).not.toBe('未提交财务')
+ expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false)
+})
diff --git a/src/modules/overseas-master-data/components/VehicleCareEditor.vue b/src/modules/overseas-master-data/components/VehicleCareEditor.vue
new file mode 100644
index 00000000..39cdef5e
--- /dev/null
+++ b/src/modules/overseas-master-data/components/VehicleCareEditor.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+ 正在核对原保存结果,请保留当前窗口并重试。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。
+
+
+
+
+
+ 附件图片(JPG / PNG,每张最大 10MiB)
+ 已保存 {{ current?.files.length || 0 }} 张,待上传 {{ selected.length }} 张。记录使用所选单位,不自动修改车辆公里数。
+
+
+
diff --git a/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.test.ts b/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.test.ts
new file mode 100644
index 00000000..45a6fa6f
--- /dev/null
+++ b/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.test.ts
@@ -0,0 +1,138 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
+import VehicleCareFinanceLedger from './VehicleCareFinanceLedger.vue'
+import * as api from '../api/companyVehicleCosts'
+import { ApiResponseError } from '@/shared/api/http'
+const access = vi.hoisted(() => ({ permissions: new Set() }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) }))
+vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
+vi.mock('../api/companyVehicleCosts', async original => ({ ...(await original()), getCompanyCost: vi.fn(), commandCompanyCost: vi.fn(), payCompanyCost: vi.fn(), listCompanyCostHistory: vi.fn(), listCompanyCostPayments: vi.fn(), uploadCompanyCostVoucher: vi.fn(), downloadCompanyCostVoucher: vi.fn() }))
+const detail: api.CompanyCost = { costNo: 'VC-ONE', subjectCode: 'PLATFORM_DEFAULT', supplierName: '维修供应商', currency: 'CNY', status: 'APPROVED', recordVersion: '2', paidAmount: 0, outstandingAmount: 70,
+ source: { sourceType: 'REPAIR', sourceNo: 'VR-ONE', sourceVersion: '1', vehicleId: '71', vehiclePlate: 'VEH-A', tripNo: null, supplierId: '8', feeName: '维修', businessDate: '2026-01-01', originalCurrency: 'USD', originalAmount: 10, exchangeRate: 7, exchangeRateMonth: '2026-01', amount: 70, remark: null } }
+const empty = { records: [], page: 1, size: 10, total: 0, pages: 0 }
+const voucher = { fileId: '11', fileName: 'payment.png', contentType: 'image/png', sizeBytes: 3 }
+const wrappers: VueWrapper[] = []
+const input = { props: ['modelValue'], emits: ['update:modelValue'], template: ' ' }
+function mount() {
+ const w = shallowMount(VehicleCareFinanceLedger, { props: { costNo: 'VC-ONE' }, global: { stubs: {
+ ElButton: { props: ['disabled'], template: ' ' },
+ ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true,
+ ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input,
+ DataTable: { name: 'DataTable', props: ['data', 'total', 'page'], emits: ['update:page'], template: '
' },
+ LoadErrorState: { props: ['message'], template: '{{ message }}
' }, ApiErrorMeta: true,
+ } } }); wrappers.push(w); return w
+}
+const button = (w: VueWrapper, label: string) => w.findAll('button').find(value => value.text() === label)!
+async function files(w: VueWrapper, names = ['payment.png']) {
+ const element = w.get('input[type="file"]')
+ Object.defineProperty(element.element, 'files', { configurable: true, value: names.map(name => new File(['png'], name, { type: 'image/png' })) })
+ await element.trigger('change'); await flushPromises()
+}
+beforeEach(() => {
+ vi.resetAllMocks(); access.permissions = new Set(Object.values(api.companyCostPermissions))
+ vi.mocked(api.getCompanyCost).mockResolvedValue(structuredClone(detail))
+ vi.mocked(api.commandCompanyCost).mockResolvedValue({ ...detail, status: 'VOIDED', outstandingAmount: null, recordVersion: '3' })
+ vi.mocked(api.payCompanyCost).mockResolvedValue({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' })
+ vi.mocked(api.listCompanyCostHistory).mockResolvedValue(empty); vi.mocked(api.listCompanyCostPayments).mockResolvedValue(empty)
+ vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValue(voucher)
+})
+afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
+it('requests detail once and reads separately paged history only on demand', async () => {
+ const w = mount(); await flushPromises()
+ expect(api.getCompanyCost).toHaveBeenCalledExactlyOnceWith('VC-ONE')
+ expect(api.listCompanyCostHistory).not.toHaveBeenCalled(); expect(api.listCompanyCostPayments).not.toHaveBeenCalled()
+ await button(w, '操作历史').trigger('click'); await flushPromises()
+ expect(api.listCompanyCostHistory).toHaveBeenCalledExactlyOnceWith('VC-ONE', 1)
+ w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
+ expect(api.listCompanyCostHistory).toHaveBeenLastCalledWith('VC-ONE', 2)
+ await button(w, '付款流水').trigger('click'); await flushPromises()
+ expect(api.listCompanyCostPayments).toHaveBeenCalledExactlyOnceWith('VC-ONE', 1)
+})
+it('has no requests without Finance list permission and does not give readers write buttons', async () => {
+ access.permissions.clear(); const denied = mount(); await flushPromises(); expect(api.getCompanyCost).not.toHaveBeenCalled()
+ expect(denied.text()).toContain('暂无公司成本财务查询权限')
+ access.permissions.add(api.companyCostPermissions.list); const w = mount(); await flushPromises()
+ expect(button(w, '登记付款')).toBeUndefined(); expect(button(w, '作废费用')).toBeUndefined(); expect(button(w, '确认入账')).toBeUndefined()
+})
+it('keeps one immutable payment request and key after an unknown response', async () => {
+ vi.mocked(api.payCompanyCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' })
+ const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
+ w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20); await files(w)
+ await button(w, '确认操作').trigger('click'); await flushPromises()
+ const first = vi.mocked(api.payCompanyCost).mock.calls[0]!
+ expect(first).toEqual(['VC-ONE', expect.any(String), expect.objectContaining({ expectedVersion: '2', amount: 20, voucherFileIds: ['11'] })])
+ expect(w.text()).toContain('上次操作结果尚未确认')
+ w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 60)
+ await button(w, '重试原操作').trigger('click'); await flushPromises()
+ expect(vi.mocked(api.payCompanyCost).mock.calls[1]).toEqual(first)
+ expect(w.text()).toContain('部分付款'); expect(w.text()).toContain('未付 CNY 50.00')
+ expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1); expect(w.emitted('changed')).toHaveLength(1)
+})
+it('preserves successfully uploaded vouchers and resumes remaining uploads without paying', async () => {
+ vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValueOnce(voucher).mockRejectedValueOnce(new Error('upload failed'))
+ .mockResolvedValueOnce({ ...voucher, fileId: '12', fileName: 'second.png' })
+ const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
+ await files(w, ['payment.png', 'second.png'])
+ expect(w.text()).toContain('已上传:payment.png'); expect(w.text()).toContain('1 张凭证待完成上传')
+ expect(api.payCompanyCost).not.toHaveBeenCalled()
+ await button(w, '重试剩余凭证').trigger('click'); await flushPromises()
+ expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(3)
+ expect((vi.mocked(api.uploadCompanyCostVoucher).mock.calls[2]![1]).name).toBe('second.png')
+ expect(w.text()).toContain('已上传:second.png'); expect(api.payCompanyCost).not.toHaveBeenCalled()
+})
+it('refuses zero, excess or unproven payments locally and sends void only after explicit confirmation', async () => {
+ const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
+ w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 0); await button(w, '确认操作').trigger('click'); await flushPromises()
+ await files(w); w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 71)
+ await button(w, '确认操作').trigger('click'); await flushPromises(); expect(api.payCompanyCost).not.toHaveBeenCalled()
+ await button(w, '取消操作').trigger('click'); await button(w, '作废费用').trigger('click'); await flushPromises()
+ expect(api.commandCompanyCost).not.toHaveBeenCalled()
+ await button(w, '确认操作').trigger('click'); await flushPromises()
+ expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'void', expect.any(String), { expectedVersion: '2', reason: null })
+ expect(w.text()).toContain('已作废')
+})
+it('cannot void or pay again when the server says paid, and booking is an independent action', async () => {
+ vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, status: 'PAID', paidAmount: 70, outstandingAmount: 0 })
+ const paid = mount(); await flushPromises(); expect(button(paid, '作废费用')).toBeUndefined(); expect(button(paid, '登记付款')).toBeUndefined()
+ vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, status: 'PENDING', recordVersion: '0', outstandingAmount: null })
+ const pending = mount(); await flushPromises(); await button(pending, '确认入账').trigger('click'); await button(pending, '确认操作').trigger('click'); await flushPromises()
+ expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'approve', expect.any(String), { expectedVersion: '0', reason: null })
+ expect(api.payCompanyCost).not.toHaveBeenCalled()
+})
+
+it('does not carry another cost payment draft into the next record', async () => {
+ const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
+ w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
+ await w.get('input[placeholder="填写银行流水号或支付凭据号"]').setValue('previous-cost-reference')
+ await files(w)
+ vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, costNo: 'VC-TWO' })
+ await w.setProps({ costNo: 'VC-TWO' }); await flushPromises()
+ await button(w, '登记付款').trigger('click'); await flushPromises()
+ expect(w.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBeNull()
+ expect((w.get('input[placeholder="填写银行流水号或支付凭据号"]').element as HTMLInputElement).value).toBe('')
+ expect(w.text()).not.toContain('已上传:payment.png')
+})
+
+it.each(['A0422', 'FIN_409'])('unlocks a definite HTTP200 %s rejection while retaining entered payment and vouchers', async code => {
+ vi.mocked(api.payCompanyCost).mockRejectedValueOnce(new ApiResponseError('明确拒绝', code, 200))
+ const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
+ w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20); await files(w)
+ await button(w, '确认操作').trigger('click'); await flushPromises()
+ const first = vi.mocked(api.payCompanyCost).mock.calls[0]!
+ expect(w.text()).not.toContain('上次操作结果尚未确认'); expect(w.text()).toContain('已上传:payment.png')
+ w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
+ await button(w, '确认操作').trigger('click'); await flushPromises()
+ const second = vi.mocked(api.payCompanyCost).mock.calls[1]!
+ expect(second[1]).not.toBe(first[1]); expect(second[2].amount).toBe(10)
+ expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1)
+})
+
+it('ignores an old detail response after another cost is opened', async () => {
+ let finish!: (value: api.CompanyCost) => void
+ vi.mocked(api.getCompanyCost).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
+ const w = mount()
+ vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, costNo: 'VC-TWO' })
+ await w.setProps({ costNo: 'VC-TWO' }); await flushPromises()
+ finish(detail); await flushPromises()
+ expect(w.text()).toContain('VC-TWO'); expect(w.text()).not.toContain('VC-ONE')
+})
diff --git a/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue b/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue
new file mode 100644
index 00000000..acedf155
--- /dev/null
+++ b/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue
@@ -0,0 +1,205 @@
+
+
+
+
+ 暂无公司成本财务查询权限。
+
+ 刷新费用 费用详情 操作历史 付款流水
+
+
+ 上次操作结果尚未确认。重试使用原版本、原内容和原请求键。
+
+ {{ cost.costNo }} · {{ statusLabels[cost.status] }} · {{ cost.supplierName }}
+ {{ cost.source.feeName }} · {{ cost.source.businessDate }} · 原币 {{ cost.source.originalCurrency }} {{ cost.source.originalAmount.toFixed(2) }}
+ 汇率月份 {{ cost.source.exchangeRateMonth }} · 汇率 {{ cost.source.exchangeRate }} · 折算 {{ money(cost.source.amount) }}
+ 已付 {{ money(cost.paidAmount) }} · 未付 {{ money(cost.outstandingAmount) }}
+ 确认入账 作废费用 登记付款
+
+
+
+
+
+
+
+
+ 登记新付款需公司付款凭证上传权限。
+ 已上传:{{ file.fileName }}
+ {{ pendingFiles.length }} 张凭证待完成上传。
+ 重试剩余凭证
+
+
+
+
+
+
+ {{ action === 'pay' ? '确认实际付款及凭证后登记。此操作不会发起银行转账。' : action === 'void' ? '确认作废这笔未付款费用;历史记录保留,重新登记须重新入账。' : '确认将这笔公司费用入账为应付;不会自动付款。' }}
+ {{ pending ? '重试原操作' : '确认操作' }}
+ 取消操作
+
+
+
+
+ {{ ({ RECEIVE: '接收', APPROVE: '入账', VOID: '作废', PAY: '付款' } as Record)[row.action] || '—' }}
+ {{ time(row.occurredAt) }} {{ money(row.snapshot.source.amount) }}
+
+
+ {{ money(row.amount) }} {{ time(row.paidAt) }}
+ {{ methods[row.method as keyof typeof methods] || '—' }}
+ 凭证 {{ Number(index) + 1 }}
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/VehicleCareTable.vue b/src/modules/overseas-master-data/components/VehicleCareTable.vue
new file mode 100644
index 00000000..24e2ec0f
--- /dev/null
+++ b/src/modules/overseas-master-data/components/VehicleCareTable.vue
@@ -0,0 +1,214 @@
+
+
+
+ {{ name }}
+
+ {{ photoError.message }}
+
+
+ 添加{{ name }}
+
+ {{ new Date(row.details.occurredAt).toLocaleString('zh-CN', { hour12: false }) }}
+ {{ row.details.lastServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}
+ {{ row.details.odometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}
+ {{ row.details.nextServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}
+ {{ row.details.fuelQuantity }} {{ fuelLabels[row.details.fuelUnit as keyof typeof fuelLabels] }}
+ {{ exceptionLabels[row.details.incident?.exceptionType as keyof typeof exceptionLabels] || '—' }}
+ {{ row.details.incident?.reportedCost == null ? '—' : `${row.details.incident.currency} ${row.details.incident.reportedCost.toFixed(2)}` }}
+ {{ financeLoading ? '查询中…' : financeError ? '状态未确认' : financeStates[row.recordNo] ? vehicleCareFinanceLabels[financeStates[row.recordNo]!.status] : '未查询' }}
+ {{ file.fileName }} 暂无附件
+ {{ meterRecord ? (row.details.verificationStatus === 'VERIFIED' ? '已核验' : '待核验') : processingLabels[row.details.incident?.processingStatus as keyof typeof processingLabels] || '—' }}
+
+
+
+ 按公司成本登记,不生成客户应收。财务接收、入账和付款分别确认。
+ {{ financeTarget.recordNo }} · {{ financeTarget.vehiclePlate }} · 申报费用 {{ financeTarget.details.incident?.currency || '—' }} {{ financeTarget.details.incident?.reportedCost?.toFixed(2) ?? '—' }}
+ {{ vehicleCareFinanceLabels[financeStatus.status] }}
+
+ {{ financeStatus.finance.costNo }} · {{ financeStatus.finance.supplierName }} · CNY {{ financeStatus.finance.source.amount.toFixed(2) }}
+ 已付 CNY {{ financeStatus.finance.paidAmount.toFixed(2) }} · 未付 {{ financeStatus.finance.outstandingAmount == null ? '未形成应付' : `CNY ${financeStatus.finance.outstandingAmount.toFixed(2)}` }}
+
+
+ 提交结果待核对;重试使用原请求,不重复登记。
+
+
+
+
+ 提交公司成本还需供应商查询权限。
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/VehicleFulfillmentTable.test.ts b/src/modules/overseas-master-data/components/VehicleFulfillmentTable.test.ts
new file mode 100644
index 00000000..27ab2e95
--- /dev/null
+++ b/src/modules/overseas-master-data/components/VehicleFulfillmentTable.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
+import VehicleFulfillmentTable from './VehicleFulfillmentTable.vue'
+import * as api from '../api/vehicleFulfillment'
+const permissions = vi.hoisted(() => ({ allowed: true }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => permissions.allowed }) }))
+vi.mock('../api/vehicleFulfillment', () => ({ listVehicleDeliveries: vi.fn(), listVehicleAssignmentChanges: vi.fn() }))
+const wrappers: VueWrapper[] = []
+const result = (number: string) => ({ records: [{ deliveryNo: number }], page: 1, size: 10, total: 1, pages: 1 })
+function mount(kind: 'deliveries' | 'changes' = 'deliveries') {
+ const wrapper = shallowMount(VehicleFulfillmentTable, { props: { vehicleId: '900719925474099301', kind }, global: { stubs: {
+ DataTable: { name: 'DataTable', props: ['data','columns','page','pageSize','total','loading'], emits: ['update:page','update:pageSize'], template: '
' },
+ ElButton: { template: ' ' }, LoadErrorState: { name: 'LoadErrorState', props: ['message'], template: '{{ message }}
' }, ApiErrorMeta: true,
+ } } }); wrappers.push(wrapper); return wrapper
+}
+beforeEach(() => {
+ vi.clearAllMocks(); permissions.allowed = true
+ vi.mocked(api.listVehicleDeliveries).mockResolvedValue(result('DL-1') as Awaited>)
+ vi.mocked(api.listVehicleAssignmentChanges).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
+})
+afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()) })
+it('loads only its displayed projection once and does not fetch per-row details', async () => {
+ const wrapper = mount(); await flushPromises()
+ expect(api.listVehicleDeliveries).toHaveBeenCalledExactlyOnceWith('900719925474099301',1,10)
+ expect(api.listVehicleAssignmentChanges).not.toHaveBeenCalled()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(result('DL-1').records)
+})
+it('does not call either endpoint without the required viewing permissions', async () => {
+ permissions.allowed = false; const wrapper = mount(); await flushPromises()
+ expect(api.listVehicleDeliveries).not.toHaveBeenCalled(); expect(api.listVehicleAssignmentChanges).not.toHaveBeenCalled()
+ expect(wrapper.text()).toContain('暂无派送记录查看权限')
+})
+it('changing vehicle from a later page resets pagination and sends one scoped request', async () => {
+ const wrapper = mount(); await flushPromises()
+ wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 3); await flushPromises()
+ vi.mocked(api.listVehicleDeliveries).mockClear()
+ await wrapper.setProps({ vehicleId: '900719925474099302' }); await flushPromises()
+ expect(api.listVehicleDeliveries).toHaveBeenCalledExactlyOnceWith('900719925474099302',1,10)
+})
+it('a late response for the previous vehicle cannot replace the new vehicle records', async () => {
+ let resolveOld!: (value: Awaited>) => void
+ vi.mocked(api.listVehicleDeliveries).mockImplementationOnce(() => new Promise(resolve => { resolveOld=resolve }))
+ const wrapper = mount()
+ await wrapper.setProps({ vehicleId: '900719925474099302' }); await flushPromises()
+ resolveOld(result('DL-OLD') as Awaited>); await flushPromises()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(result('DL-1').records)
+})
+it('changes use the history endpoint and do not reveal current customer delivery data', async () => {
+ mount('changes'); await flushPromises()
+ expect(api.listVehicleAssignmentChanges).toHaveBeenCalledExactlyOnceWith('900719925474099301',1,10)
+ expect(api.listVehicleDeliveries).not.toHaveBeenCalled()
+})
+it('failed reads show an error and clear stale rows instead of showing unrelated prior data', async () => {
+ const wrapper = mount(); await flushPromises()
+ vi.mocked(api.listVehicleDeliveries).mockRejectedValueOnce(new Error('读取失败'))
+ await wrapper.find('button').trigger('click'); await flushPromises()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
+})
diff --git a/src/modules/overseas-master-data/components/VehicleFulfillmentTable.vue b/src/modules/overseas-master-data/components/VehicleFulfillmentTable.vue
new file mode 100644
index 00000000..a7aa8f4b
--- /dev/null
+++ b/src/modules/overseas-master-data/components/VehicleFulfillmentTable.vue
@@ -0,0 +1,81 @@
+
+
+
+ 暂无{{ kind === 'deliveries' ? '派送记录' : '车次历史' }}查看权限。
+
+ {{ kind === 'deliveries' ? '计划中的派送按当前分配车辆展示,实际发车后按发车记录归属车辆。' : '按车次保存更换前后的真实记录;同车次可能关联多张排车单。' }}
+
+
+ 刷新
+ {{ row.bolNo || '—' }}
{{ row.dispatchNo || '未关联排车' }}
+ {{ row.customerName || '—' }}
+ {{ row.destinationType === 'PRIVATE' ? row.deliveryAddress || '—' : row.destinationWarehouseCode || row.destinationWarehouseName || '—' }}
+ {{ date(kind === 'deliveries' ? row.plannedDeliveryAt : row.occurredAt) }}
+ {{ statuses[row.status] || row.status || '—' }}
+ {{ relations[row.relationship] || '—' }}
{{ row.tripNo }}
+ {{ number }}
未关联排车
+ {{ row.previousVehiclePlate || '—' }} → {{ row.vehiclePlate || '—' }}
+ {{ row.previousDriverName || '—' }} → {{ row.driverName || '—' }}
+ —
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/driverTypes.ts b/src/modules/overseas-master-data/driverTypes.ts
new file mode 100644
index 00000000..b274fb98
--- /dev/null
+++ b/src/modules/overseas-master-data/driverTypes.ts
@@ -0,0 +1,74 @@
+export type DriverReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
+export type DriverQualificationReason = 'ELIGIBLE' | 'INACTIVE' | 'MISSING_LICENSE' | 'MISSING_EXPIRY'
+ | 'PENDING_REVIEW' | 'REJECTED' | 'EXPIRED' | 'MISSING_TRIP_END' | 'INVALID_TRIP_END' | 'EXPIRES_DURING_TRIP'
+
+export interface DriverDraft {
+ name: string
+ phone: string
+ ownership: 'OWNED' | 'DISPATCHED'
+ region: 'DOMESTIC' | 'OVERSEAS'
+ licenseNumber: string | null
+ licenseType: string | null
+ licenseIssuedOn: string | null
+ licenseExpiresOn: string | null
+ licenseZone: string
+ remark: string | null
+ profile: DriverProfile | null
+}
+
+export interface DriverProfile {
+ warehouseId: string | null
+ vehicleModels: string | null
+ languagesAndArea: string | null
+ medicalExpiresOn: string | null
+ trainingExpiresOn: string | null
+ insuranceExpiresOn: string | null
+}
+export interface DriverWarehouseOption { id: string; code: string; name: string; region: DriverDraft['region'] }
+export type DriverLicenseState = 'MISSING' | 'VALID' | 'EXPIRING' | 'EXPIRED'
+export interface DriverSummary { total: number; owned: number; dispatched: number; expiringSoon: number; expired: number }
+
+export interface DriverQualification {
+ canDispatch: boolean
+ expiringSoon: boolean
+ reason: DriverQualificationReason
+ licenseExpiresOn: string | null
+}
+
+export interface DriverDetail {
+ id: string
+ driverNo: string
+ details: DriverDraft
+ active: boolean
+ reviewStatus: DriverReviewStatus
+ licenseFileId: string | null
+ recordVersion: number
+ updatedBy: string
+ updatedAt: string
+ qualification: DriverQualification
+ licenseState: DriverLicenseState
+ warehouseCode: string | null
+ warehouseName: string | null
+}
+
+export interface DriverHistory {
+ recordVersion: number
+ action: 'CREATE' | 'UPDATE' | 'ACTIVATE' | 'DEACTIVATE' | 'UPLOAD_LICENSE' | 'APPROVED' | 'REJECTED' | 'RESUBMIT'
+ reason: string | null
+ operatorId: string
+ occurredAt: string
+ snapshot: DriverDetail
+}
+
+export const driverReviewLabels: Record = {
+ PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回',
+}
+export const driverLicenseLabels: Record = {
+ MISSING: '待补有效期', VALID: '有效', EXPIRING: '30天内到期', EXPIRED: '已过期',
+}
+
+export const driverQualificationLabels: Record = {
+ ELIGIBLE: '本次任务可派车', INACTIVE: '已停用', MISSING_LICENSE: '待上传驾驶证', MISSING_EXPIRY: '待补有效期',
+ PENDING_REVIEW: '证照待审核', REJECTED: '证照已打回', EXPIRED: '驾驶证已过期',
+ MISSING_TRIP_END: '待填写任务结束时间', INVALID_TRIP_END: '预计结束时间无效', EXPIRES_DURING_TRIP: '任务结束前证照将过期',
+}
diff --git a/src/modules/overseas-master-data/fleetQuoteCsv.test.ts b/src/modules/overseas-master-data/fleetQuoteCsv.test.ts
new file mode 100644
index 00000000..7e7c2c67
--- /dev/null
+++ b/src/modules/overseas-master-data/fleetQuoteCsv.test.ts
@@ -0,0 +1,14 @@
+import { expect, it } from 'vitest'
+import { fleetQuoteCsvHeader, parseFleetQuoteCsv } from './fleetQuoteCsv'
+const csv = (row: string) => '\uFEFF' + fleetQuoteCsvHeader.join(',') + '\r\n' + row
+it('preserves zero, missing prices, quoted cities and multiline notes', () => {
+ const rows = parseFleetQuoteCsv(csv('"Los Angeles, CA",ONT8,10.5,USD,0,,20,2026-01-01,2026-12-31,"first\nsecond"'), 'SALE', '90071992547409931')
+ expect(rows[0]).toMatchObject({ kind: 'SALE', partyId: '90071992547409931', originCity: 'Los Angeles, CA', selfPickupPrice: 0, fullDeliveryPrice: null, looseDeliveryPrice: 20, remark: 'first\nsecond' })
+})
+it.each(['LA,ONT8,10,USD,,,,2026-01-01,2026-12-31,', 'LA,ONT8,10,USD,1,,,2026-02-30,2026-12-31,', 'LA,ONT8,10,USD,1,,,2026-12-31,2026-01-01,', 'LA,ONT8,10,USD,-1,,,2026-01-01,2026-12-31,'])('rejects invalid data before submission: %s', row => {
+ expect(() => parseFleetQuoteCsv(csv(row), 'PURCHASE', '1')).toThrow('第2行')
+})
+it('rejects truncated CSV and oversized batch', () => {
+ expect(() => parseFleetQuoteCsv(csv('"LA'), 'SALE', '1')).toThrow('引号')
+ expect(() => parseFleetQuoteCsv(csv(Array(201).fill('LA,ONT8,10,USD,1,,,2026-01-01,2026-12-31,').join('\n')), 'SALE', '1')).toThrow('200')
+})
diff --git a/src/modules/overseas-master-data/fleetQuoteCsv.ts b/src/modules/overseas-master-data/fleetQuoteCsv.ts
new file mode 100644
index 00000000..f7cfa1fc
--- /dev/null
+++ b/src/modules/overseas-master-data/fleetQuoteCsv.ts
@@ -0,0 +1,31 @@
+import type { FleetQuoteDraft, FleetQuoteKind } from './api/fleetQuotes'
+export const fleetQuoteCsvHeader = ['发货城市', '目的仓码', '距离(mile)', '币种', '整车自提', '整车派送', '散板派送', '生效日', '失效日', '备注']
+export function parseFleetQuoteCsv(text: string, kind: FleetQuoteKind, partyId: string): FleetQuoteDraft[] {
+ if (text.length > 500000) throw new Error('CSV不能超过500KB')
+ const rows: string[][] = []; let row: string[] = [], cell = '', quoted = false
+ const input = text.replace(/^\uFEFF/, '')
+ for (let i = 0; i < input.length; i++) {
+ const c = input[i]
+ if (c === '"') { if (quoted && input[i + 1] === '"') { cell += '"'; i++ } else quoted = !quoted }
+ else if (!quoted && (c === ',' || c === '\n')) { row.push(cell.trim()); cell = ''; if (c === '\n') { rows.push(row); row = [] } }
+ else if (!quoted && c === '\r') continue
+ else cell += c
+ }
+ if (quoted) throw new Error('CSV引号未闭合')
+ if (cell || row.length) { row.push(cell.trim()); rows.push(row) }
+ if (rows.shift()?.join('|') !== fleetQuoteCsvHeader.join('|')) throw new Error('CSV表头不匹配,请下载模板')
+ const data = rows.filter(r => r.some(Boolean))
+ if (!data.length || data.length > 200) throw new Error('每次导入1至200条报价')
+ return data.map((r, index) => {
+ const fail = (message: string): never => { throw new Error(`第${index + 2}行:${message}`) }
+ if (r.length !== 10 || !r[0] || !r[1]) fail('请填写发货城市、目的仓码,并核对列数')
+ const amount = (v: string | undefined): number | null => { if (!v) return null; if (!/^\d{1,10}(\.\d{1,2})?$/.test(v)) return fail('报价须为非负金额,最多两位小数'); return Number(v) }
+ if (!/^\d{1,7}(\.\d)?$/.test(r[2] || '')) fail('请填写非负距离,最多一位小数')
+ if (r[3] !== 'USD' && r[3] !== 'CNY') fail('币种仅支持USD或CNY')
+ const date = (v: string | undefined): string => { if (!v || !/^\d{4}-\d{2}-\d{2}$/.test(v) || Number.isNaN(Date.parse(v)) || new Date(v).toISOString().slice(0, 10) !== v) return fail('日期须为有效的YYYY-MM-DD'); return v }
+ const draft: FleetQuoteDraft = { kind, partyId, originCity: r[0]!, destinationCode: r[1]!, distanceMiles: Number(r[2]), currency: r[3] as 'USD' | 'CNY', selfPickupPrice: amount(r[4]), fullDeliveryPrice: amount(r[5]), looseDeliveryPrice: amount(r[6]), validFrom: date(r[7]), validTo: date(r[8]), remark: r[9] || null }
+ if (draft.validTo < draft.validFrom) fail('失效日不得早于生效日')
+ if (draft.selfPickupPrice == null && draft.fullDeliveryPrice == null && draft.looseDeliveryPrice == null) fail('至少填写一种价格')
+ return draft
+ })
+}
diff --git a/src/modules/overseas-master-data/pages/DriverDetailView.vue b/src/modules/overseas-master-data/pages/DriverDetailView.vue
new file mode 100644
index 00000000..fc702e6f
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/DriverDetailView.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
{{ label }} {{ value || '—' }}
备注 {{ detail.details.remark || '—' }}
+
{{ label }} {{ value || '—' }}
+
+ 查看驾驶证 尚未上传驾驶证 上传 / 替换驾驶证
+ 驾驶证将在 30 天内到期;预计任务结束前仍有效时允许派车,请及时更新。
+
+ 重新提交审核
+ 司机预计任务结束时间({{ browserZone }}) 校验本次任务资格 {{ qualificationStale ? '预计时间或档案已变更,请重新校验' : driverQualificationLabels[detail.qualification.reason] }}
+
+
+ 查看操作历史
+
+ 查看当时驾驶证 —
+ {{ actionLabels[row.action as DriverHistory['action']] }}
+ {{ driverReviewLabels[row.snapshot.reviewStatus as keyof typeof driverReviewLabels] }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/pages/DriversBehavior.test.ts b/src/modules/overseas-master-data/pages/DriversBehavior.test.ts
new file mode 100644
index 00000000..8e12686c
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/DriversBehavior.test.ts
@@ -0,0 +1,391 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
+import { defineComponent, h, KeepAlive, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { ApiResponseError } from '@/shared/api/http'
+import * as api from '../api/drivers'
+import type { DriverDetail } from '../driverTypes'
+import DriversView from './DriversView.vue'
+import DriverDetailView from './DriverDetailView.vue'
+import DriverEditorDialog from '../components/DriverEditorDialog.vue'
+import DriverLicensePreview from '../components/DriverLicensePreview.vue'
+
+const security = vi.hoisted(() => ({ allowWrite: true }))
+const routing = vi.hoisted(() => ({ route: null as null | { name: string; params: { driverId?: string | string[] } } }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || security.allowWrite }) }))
+vi.mock('vue-router', async () => {
+ const { reactive } = await import('vue')
+ routing.route = reactive({ name: 'overseas-master-data-driver-detail', params: { driverId: '9223372036854775701' } })
+ return { useRouter: () => ({ push: vi.fn() }), useRoute: () => routing.route }
+})
+vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '暂停' }) } }))
+vi.mock('../api/drivers', () => ({ listDrivers: vi.fn(), getDriver: vi.fn(), listDriverHistory: vi.fn(), listDriverWarehouses: vi.fn(), createDriver: vi.fn(), updateDriver: vi.fn(), changeDriverStatus: vi.fn(), reviewDriver: vi.fn(), submitDriver: vi.fn(), getDriverQualification: vi.fn(), uploadDriverLicense: vi.fn(), downloadDriverLicense: vi.fn() }))
+
+const driver: DriverDetail = {
+ id: '9223372036854775701', driverNo: 'DR-TEST', details: { name: '测试司机', phone: '123', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: { warehouseId: '92', vehicleModels: '53尺拖车', languagesAndArea: '中文 / 洛杉矶', medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null } },
+ active: true, reviewStatus: 'PENDING', licenseFileId: '9223372036854775801', recordVersion: 2, updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: false, expiringSoon: true, reason: 'PENDING_REVIEW', licenseExpiresOn: '2026-10-22' }, licenseState: 'EXPIRING', warehouseCode: 'WH-US', warehouseName: '洛杉矶仓',
+}
+const layout = { template: '
' }
+const button = { props: ['disabled', 'loading'], template: ' ' }
+const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm', 'update:modelValue'], template: '保存
' }
+const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: ' ' }
+const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total', 'loading'], emits: ['update:page', 'update:pageSize'], template: '' }
+const stubs = { AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true, RowActions: true, StatusBadge: true, LoadErrorState: true, AppDialog: dialog, DriverEditorDialog: true, DriverLicensePreview: true, ElButton: button, ElForm: layout, ElFormItem: layout, ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElOption: true, ElDatePicker: { ...input, name: 'ElDatePicker' } }
+const wrappers: VueWrapper[] = []
+function mount(component: object, props: Record = {}): VueWrapper {
+ const wrapper = shallowMount(component, { props, global: { stubs: { ...stubs, DriverEditorDialog: component === DriverEditorDialog ? false : true, DriverLicensePreview: component === DriverLicensePreview ? false : true }, directives: { loading: () => undefined } } })
+ wrappers.push(wrapper)
+ return wrapper
+}
+function findButton(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(value => value.text().includes(text)); expect(found).toBeDefined(); return found! }
+function cachedDetails() {
+ const showing = ref(driver.id)
+ const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showing.value ? h(DriverDetailView, { key: showing.value }) : h('div', '司机列表') }) })
+ const wrapper = shallowMount(host, { global: { stubs: { ...stubs, KeepAlive: false, DriverDetailView: false }, directives: { loading: () => undefined } } })
+ wrappers.push(wrapper)
+ return { wrapper, show(id: string | null) {
+ routing.route!.name = id ? 'overseas-master-data-driver-detail' : 'overseas-master-data-drivers'
+ routing.route!.params = id ? { driverId: id } : {}
+ showing.value = id
+ } }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks(); security.allowWrite = true
+ routing.route!.name = 'overseas-master-data-driver-detail'
+ routing.route!.params = { driverId: driver.id }
+ vi.mocked(api.listDrivers).mockResolvedValue({ records: [structuredClone(driver)], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } })
+ vi.mocked(api.getDriver).mockResolvedValue(structuredClone(driver))
+ vi.mocked(api.listDriverWarehouses).mockResolvedValue([{ id: '92', code: 'WH-US', name: '洛杉矶仓', region: 'OVERSEAS' }])
+ vi.mocked(api.listDriverHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
+ vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn().mockReturnValue('blob:test-license'), revokeObjectURL: vi.fn() }))
+})
+afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.unstubAllGlobals() })
+
+describe('driver page workflows', () => {
+ it.each(['success', 'failure'] as const)('keeps B after cached A detail arrives with late %s', async outcome => {
+ let resolveA!: (value: DriverDetail) => void, rejectA!: (reason: Error) => void
+ const pendingA = new Promise((resolve, reject) => { resolveA = resolve; rejectA = reject })
+ const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } }
+ vi.mocked(api.getDriver).mockImplementation(id => id === driver.id ? pendingA : Promise.resolve(other))
+ const { wrapper, show } = cachedDetails(); await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledExactlyOnceWith(driver.id)
+ show(null); await flushPromises(); show(other.id); await flushPromises()
+ expect(wrapper.text()).toContain('另一司机')
+ const before = wrapper.text()
+ if (outcome === 'success') resolveA({ ...structuredClone(driver), details: { ...driver.details, name: '迟到A司机' } })
+ else rejectA(new Error('迟到A详情失败'))
+ await flushPromises()
+ expect(wrapper.text()).toBe(before)
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
+ const fresh = { ...structuredClone(driver), details: { ...driver.details, name: '新请求A司机' } }
+ vi.mocked(api.getDriver).mockResolvedValueOnce(fresh)
+ show(null); await flushPromises(); show(driver.id); await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(3)
+ expect(api.getDriver).toHaveBeenLastCalledWith(driver.id)
+ expect(wrapper.text()).toContain('新请求A司机')
+ expect(wrapper.text()).not.toContain('迟到A')
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
+ })
+
+ it.each(['success', 'failure'] as const)('keeps B history after cached A history arrives with late %s', async outcome => {
+ type HistoryPage = Awaited>
+ let resolveA!: (value: HistoryPage) => void, rejectA!: (reason: Error) => void
+ const pendingA = new Promise((resolve, reject) => { resolveA = resolve; rejectA = reject })
+ const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } }
+ const history = (snapshot: DriverDetail, total: number): HistoryPage => ({ records: [{ recordVersion: snapshot.recordVersion, action: 'CREATE', reason: null, operatorId: 'test-operator', occurredAt: '2026-10-02T12:00:00', snapshot }], page: 1, size: 20, total, pages: 1 })
+ const bHistory = history(other, 15), aHistory = history(driver, 3)
+ vi.mocked(api.getDriver).mockImplementation(async id => structuredClone(id === driver.id ? driver : other))
+ vi.mocked(api.listDriverHistory).mockImplementation(id => id === driver.id ? pendingA : Promise.resolve(bHistory))
+ const { wrapper, show } = cachedDetails(); await flushPromises()
+ await findButton(wrapper, '查看操作历史').trigger('click'); await flushPromises()
+ expect(api.listDriverHistory).toHaveBeenCalledExactlyOnceWith(driver.id, 1, 20)
+ show(null); await flushPromises(); show(other.id); await flushPromises()
+ await findButton(wrapper, '查看操作历史').trigger('click'); await flushPromises()
+ const before = wrapper.text(), tableB = wrapper.findComponent({ name: 'DataTable' })
+ expect(tableB.props('data')).toEqual(bHistory.records); expect(tableB.props('total')).toBe(15); expect(tableB.props('loading')).toBe(false)
+ if (outcome === 'success') resolveA(aHistory)
+ else rejectA(new Error('迟到A历史失败'))
+ await flushPromises()
+ expect(wrapper.text()).toBe(before)
+ expect(tableB.props('data')).toEqual(bHistory.records); expect(tableB.props('total')).toBe(15); expect(tableB.props('loading')).toBe(false)
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
+ show(null); await flushPromises(); show(driver.id); await flushPromises()
+ expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(false)
+ expect(api.listDriverHistory).toHaveBeenCalledTimes(2)
+ vi.mocked(api.listDriverHistory).mockResolvedValueOnce(aHistory)
+ await findButton(wrapper, '查看操作历史').trigger('click'); await flushPromises()
+ expect(api.listDriverHistory).toHaveBeenCalledTimes(3)
+ expect(api.listDriverHistory).toHaveBeenLastCalledWith(driver.id, 1, 20)
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(aHistory.records)
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(3)
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
+ })
+
+ it('does not fetch an absent driver on return to list and loads the valid driver on reentry', async () => {
+ const { wrapper, show } = cachedDetails(); await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledExactlyOnceWith(driver.id)
+ show(null)
+ await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(1)
+ const next = { ...structuredClone(driver), recordVersion: 3, details: { ...driver.details, name: '当前司机' } }
+ vi.mocked(api.getDriver).mockResolvedValueOnce(next)
+ show(next.id)
+ await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(2)
+ expect(api.getDriver).toHaveBeenLastCalledWith(next.id)
+ expect(wrapper.text()).toContain('当前司机')
+ })
+
+ it('loads only the active driver instance when two parameter-keyed detail pages are cached', async () => {
+ const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } }
+ vi.mocked(api.getDriver).mockImplementation(async id => structuredClone(id === driver.id ? driver : other))
+ const { wrapper, show } = cachedDetails(); await flushPromises()
+ const first = wrapper.findComponent(DriverDetailView)
+ expect(api.getDriver).toHaveBeenCalledExactlyOnceWith(driver.id)
+ show(other.id); await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(2)
+ expect(api.getDriver).toHaveBeenLastCalledWith(other.id)
+ expect(first.text()).not.toContain('另一司机')
+ expect(wrapper.text()).toContain('另一司机')
+ show(driver.id); await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(3)
+ expect(api.getDriver).toHaveBeenLastCalledWith(driver.id)
+ expect(wrapper.text()).toContain('测试司机')
+ show(null); await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(3)
+ show(other.id); await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(4)
+ expect(api.getDriver).toHaveBeenLastCalledWith(other.id)
+ })
+
+ it('invalidates a pending detail response when the route leaves even before params are cleared', async () => {
+ let finish!: (value: DriverDetail) => void
+ vi.mocked(api.getDriver).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
+ const wrapper = mount(DriverDetailView); await flushPromises()
+ routing.route!.name = 'overseas-master-data-drivers'
+ await flushPromises()
+ finish({ ...structuredClone(driver), details: { ...driver.details, name: '离页迟到司机' } })
+ await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(1)
+ expect(wrapper.text()).not.toContain('离页迟到司机')
+ })
+
+ it.each([{ label: 'missing', id: undefined }, { label: 'empty', id: '' }, { label: 'array', id: [driver.id] }])('does not fabricate a detail ID from $label route params', async ({ id }) => {
+ routing.route!.params = { driverId: id }
+ mount(DriverDetailView); await flushPromises()
+ expect(api.getDriver).not.toHaveBeenCalled()
+ })
+
+ it('refreshes a cached list once on return and retains applied filters and pagination', async () => {
+ const showingList = ref(true)
+ const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showingList.value ? h(DriversView) : h('div', '司机详情') }) })
+ const wrapper = shallowMount(host, { global: { stubs: { ...stubs, KeepAlive: false, DriversView: false }, directives: { loading: () => undefined } } })
+ wrappers.push(wrapper)
+ await flushPromises()
+ expect(api.listDrivers).toHaveBeenCalledTimes(1)
+ const list = wrapper.findComponent(DriversView)
+ const toolbar = list.findComponent({ name: 'QueryToolbar' })
+ toolbar.vm.$emit('update:modelValue', { keyword: 'DR-TEST', licenseState: 'EXPIRING' })
+ toolbar.vm.$emit('search'); await flushPromises()
+ list.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
+ expect(api.listDrivers).toHaveBeenCalledTimes(3)
+ showingList.value = false; await flushPromises()
+ const refreshed = { ...structuredClone(driver), recordVersion: 3, reviewStatus: 'APPROVED' as const }
+ vi.mocked(api.listDrivers).mockResolvedValueOnce({ records: [refreshed], page: 2, size: 20, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 1, expired: 0 } })
+ showingList.value = true; await flushPromises()
+ expect(api.listDrivers).toHaveBeenCalledTimes(4)
+ expect(api.listDrivers).toHaveBeenLastCalledWith({ keyword: 'DR-TEST', licenseState: 'EXPIRING', region: undefined, ownership: undefined, active: undefined, reviewStatus: undefined, page: 2, size: 20 })
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([refreshed])
+ expect(wrapper.text()).toContain('司机总数1')
+ expect(api.getDriver).not.toHaveBeenCalled()
+ })
+
+ it('ignores a list response that arrives while its cached page is deactivated', async () => {
+ let resolveList!: (value: Awaited>) => void
+ vi.mocked(api.listDrivers).mockReturnValueOnce(new Promise(resolve => { resolveList = resolve }))
+ const showingList = ref(true)
+ const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showingList.value ? h(DriversView) : h('div', '司机详情') }) })
+ const wrapper = shallowMount(host, { global: { stubs: { ...stubs, KeepAlive: false, DriversView: false }, directives: { loading: () => undefined } } })
+ wrappers.push(wrapper)
+ await flushPromises()
+ const list = wrapper.findComponent(DriversView)
+ showingList.value = false; await flushPromises()
+ resolveList({ records: [driver], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } }); await flushPromises()
+ expect(list.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
+ showingList.value = true; await flushPromises()
+ expect(api.listDrivers).toHaveBeenCalledTimes(2)
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([driver])
+ })
+
+ it('loads one paged request with server summary and does not request each driver or file', async () => {
+ const wrapper = mount(DriversView)
+ await flushPromises()
+ expect(api.listDrivers).toHaveBeenCalledTimes(1)
+ expect(wrapper.text()).toContain('司机总数24')
+ expect(api.getDriver).not.toHaveBeenCalled()
+ expect(api.downloadDriverLicense).not.toHaveBeenCalled()
+ wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
+ await flushPromises()
+ expect(api.listDrivers).toHaveBeenCalledTimes(2)
+ expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
+ await findButton(wrapper, '即将到期').trigger('click')
+ await flushPromises()
+ expect(api.listDrivers).toHaveBeenCalledTimes(3)
+ expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, licenseState: 'EXPIRING' }))
+ })
+
+ it('shows a retryable error instead of stale summary after list failure', async () => {
+ vi.mocked(api.listDrivers).mockRejectedValueOnce(new Error('服务不可用'))
+ const wrapper = mount(DriversView)
+ await flushPromises()
+ const error = wrapper.findComponent({ name: 'LoadErrorState' })
+ expect(error.exists()).toBe(true)
+ expect(wrapper.text()).toContain('司机总数—')
+ error.vm.$emit('retry'); await flushPromises()
+ expect(api.listDrivers).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not expose write actions to a read-only user', async () => {
+ security.allowWrite = false
+ const list = mount(DriversView), detail = mount(DriverDetailView)
+ await flushPromises()
+ expect(list.text()).not.toContain('新增司机')
+ expect(detail.text()).not.toContain('审核通过')
+ expect(detail.text()).not.toContain('上传 / 替换驾驶证')
+ expect(detail.text()).not.toContain('编辑档案')
+ })
+
+ it('loads detail once, requests history only on demand, and requires a rejection reason', async () => {
+ const wrapper = mount(DriverDetailView)
+ await flushPromises()
+ expect(api.getDriver).toHaveBeenCalledTimes(1)
+ expect(api.listDriverHistory).not.toHaveBeenCalled()
+ expect(wrapper.text()).toContain('洛杉矶仓')
+ expect(wrapper.text()).not.toContain(driver.id)
+ await findButton(wrapper, '打回').trigger('click')
+ expect(api.reviewDriver).not.toHaveBeenCalled()
+ expect(ElMessage.warning).toHaveBeenCalledWith('请填写打回原因')
+ await findButton(wrapper, '查看操作历史').trigger('click'); await flushPromises()
+ expect(api.listDriverHistory).toHaveBeenCalledTimes(1)
+ })
+
+ it('submits approval with current version and uses returned state without reloading detail', async () => {
+ vi.mocked(api.reviewDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 3, reviewStatus: 'APPROVED' })
+ const wrapper = mount(DriverDetailView)
+ await flushPromises()
+ await findButton(wrapper, '审核通过').trigger('click'); await flushPromises()
+ expect(api.reviewDriver).toHaveBeenCalledWith(driver.id, 2, 'APPROVED', null)
+ expect(api.getDriver).toHaveBeenCalledTimes(1)
+ expect(wrapper.findAll('button').some(value => value.text() === '审核通过')).toBe(false)
+ })
+
+ it('keeps the editor and version after a failed write', async () => {
+ vi.mocked(api.updateDriver).mockRejectedValue(new ApiResponseError('版本已变化,请刷新', 'A0409', 200, null, 'req-driver-conflict'))
+ const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
+ await wrapper.setProps({ modelValue: true }); await flushPromises()
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.objectContaining({ name: driver.details.name }))
+ expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('版本已变化')
+ })
+
+ it('invalidates an old task preview after the planned end changes without inventing a new server qualification', async () => {
+ vi.mocked(api.getDriverQualification).mockResolvedValue({ ...driver.qualification, canDispatch: true, reason: 'ELIGIBLE' })
+ const wrapper = mount(DriverDetailView)
+ await flushPromises()
+ const picker = wrapper.findComponent({ name: 'ElDatePicker' })
+ picker.vm.$emit('update:modelValue', new Date('2026-10-20T12:00:00Z'))
+ picker.vm.$emit('change'); await flushPromises()
+ await findButton(wrapper, '校验本次任务资格').trigger('click'); await flushPromises()
+ expect(api.getDriverQualification).toHaveBeenCalledWith(driver.id, '2026-10-20T12:00:00.000Z')
+ expect(wrapper.text()).toContain('本次任务可派车')
+ picker.vm.$emit('update:modelValue', new Date('2026-11-20T12:00:00Z'))
+ picker.vm.$emit('change'); await flushPromises()
+ expect(wrapper.text()).not.toContain('本次任务可派车')
+ expect(wrapper.text()).toContain('请重新校验')
+ expect(api.getDriverQualification).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not create a second draft when attachment upload fails after saving', async () => {
+ vi.mocked(api.createDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 0 })
+ vi.mocked(api.uploadDriverLicense).mockRejectedValueOnce(new Error('文件服务不可用')).mockResolvedValueOnce({ ...structuredClone(driver), recordVersion: 1 })
+ const wrapper = mount(DriverEditorDialog, { modelValue: false, driver: null })
+ await wrapper.setProps({ modelValue: true }); await flushPromises()
+ const inputs = wrapper.findAllComponents({ name: 'ElInput' })
+ inputs[0]!.vm.$emit('update:modelValue', '新司机')
+ inputs[1]!.vm.$emit('update:modelValue', '123')
+ const selects = wrapper.findAllComponents({ name: 'ElSelect' })
+ selects.at(-1)!.vm.$emit('update:modelValue', 'UTC')
+ const file = new File(['%PDF-test'], 'license.pdf', { type: 'application/pdf' })
+ const picker = wrapper.get('input[type="file"]')
+ Object.defineProperty(picker.element, 'files', { value: [file] })
+ await picker.trigger('change')
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.createDriver).toHaveBeenCalledTimes(1)
+ expect(api.uploadDriverLicense).toHaveBeenCalledTimes(1)
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('档案已保存')
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.createDriver).toHaveBeenCalledTimes(1)
+ expect(api.updateDriver).not.toHaveBeenCalled()
+ expect(api.uploadDriverLicense).toHaveBeenCalledTimes(2)
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
+ })
+
+ it('keeps an old save outside the newly opened driver session and uploads only that driver file', async () => {
+ let finishOld!: (value: DriverDetail) => void
+ vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve }))
+ const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } }
+ const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
+ await wrapper.setProps({ modelValue: true }); await flushPromises()
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.anything())
+ await wrapper.setProps({ modelValue: false }); await flushPromises()
+ await wrapper.setProps({ driver: other, modelValue: true }); await flushPromises()
+ const file = new File(['%PDF-other'], 'other-driver.pdf', { type: 'application/pdf' })
+ const picker = wrapper.get('input[type="file"]')
+ Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
+ await picker.trigger('change')
+ finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises()
+ expect(api.uploadDriverLicense).not.toHaveBeenCalled()
+ expect(wrapper.emitted('saved')).toBeUndefined()
+ expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
+ expect(wrapper.findAllComponents({ name: 'ElInput' })[0]!.props('modelValue')).toBe('另一司机')
+ vi.mocked(api.updateDriver).mockResolvedValue({ ...other, recordVersion: 3 })
+ vi.mocked(api.uploadDriverLicense).mockResolvedValue({ ...other, recordVersion: 4 })
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.updateDriver).toHaveBeenLastCalledWith(other.id, 2, expect.objectContaining({ name: '另一司机' }))
+ expect(api.uploadDriverLicense).toHaveBeenCalledExactlyOnceWith(other.id, 3, file)
+ expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(expect.objectContaining({ id: other.id }))
+ })
+
+ it('does not continue a pending save with an upload after the editor is unmounted', async () => {
+ let finishOld!: (value: DriverDetail) => void
+ vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve }))
+ const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
+ await wrapper.setProps({ modelValue: true }); await flushPromises()
+ const picker = wrapper.get('input[type="file"]')
+ Object.defineProperty(picker.element, 'files', { value: [new File(['%PDF'], 'driver.pdf', { type: 'application/pdf' })] })
+ await picker.trigger('change')
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ wrapper.unmount()
+ finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises()
+ expect(api.uploadDriverLicense).not.toHaveBeenCalled()
+ expect(ElMessage.success).not.toHaveBeenCalled()
+ })
+
+ it('releases preview blobs when closed and ignores a download completed after close', async () => {
+ vi.mocked(api.downloadDriverLicense).mockResolvedValueOnce(new Blob(['%PDF'], { type: 'application/pdf' }))
+ const wrapper = mount(DriverLicensePreview, { modelValue: false, driverId: driver.id, fileId: driver.licenseFileId, driverName: driver.details.name })
+ await wrapper.setProps({ modelValue: true }); await flushPromises()
+ expect(wrapper.find('iframe').exists()).toBe(true)
+ await wrapper.setProps({ modelValue: false })
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-license')
+ let complete!: (value: Blob) => void
+ vi.mocked(api.downloadDriverLicense).mockReturnValueOnce(new Promise(resolve => { complete = resolve }))
+ await wrapper.setProps({ modelValue: true })
+ await wrapper.setProps({ modelValue: false })
+ complete(new Blob(['%PDF'], { type: 'application/pdf' })); await flushPromises()
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/modules/overseas-master-data/pages/DriversView.vue b/src/modules/overseas-master-data/pages/DriversView.vue
new file mode 100644
index 00000000..ba42344c
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/DriversView.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+ 司机总数 {{ summary?.total ?? '—' }} 自有 {{ summary?.owned ?? '—' }} · 外派 {{ summary?.dispatched ?? '—' }}
+ 即将到期 {{ summary?.expiringSoon ?? '—' }} 30 天内到期 · 点击筛选
+ 驾照已过期 {{ summary?.expired ?? '—' }} 不可派车 · 点击筛选
+
+
+
+
+ 新增司机
+
+ {{ row.details.ownership === 'OWNED' ? '自有司机' : '外派司机' }}
+ {{ row.details.profile?.vehicleModels || '—' }}
+ {{ row.details.licenseType || '—' }} / {{ row.details.licenseNumber || '—' }}
+ {{ row.details.licenseExpiresOn || '—' }} {{ driverLicenseLabels[row.licenseState as DriverLicenseState] }}
+ {{ row.details.profile?.medicalExpiresOn || '—' }}
+ {{ row.details.profile?.trainingExpiresOn || '—' }} / {{ row.details.profile?.insuranceExpiresOn || '—' }}
+
+ 查看附件 待上传
+
+ 共 {{ total }} 位司机 · 统计随筛选变化
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/pages/ExchangeRatesView.vue b/src/modules/overseas-master-data/pages/ExchangeRatesView.vue
index f06b385e..4bbbcfa6 100644
--- a/src/modules/overseas-master-data/pages/ExchangeRatesView.vue
+++ b/src/modules/overseas-master-data/pages/ExchangeRatesView.vue
@@ -45,13 +45,18 @@ watch([page, pageSize], load); onMounted(load)
-
-
{{ item.label }} {{ item.value }}
-
-
+
+
+
+
+ 汇率管理
+
+
+
{{ item.label }} {{ item.value }}
+
更新月度汇率 导出
- 编辑 {{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}
+ 编辑 {{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}
1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY {{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}
汇率方向:1 USD = X CNY
diff --git a/src/modules/overseas-master-data/pages/FleetQuotesView.vue b/src/modules/overseas-master-data/pages/FleetQuotesView.vue
new file mode 100644
index 00000000..bf0e2475
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/FleetQuotesView.vue
@@ -0,0 +1,52 @@
+
+
+ {{ tab[1] }}
+
+
+
+ 新增报价 批量导入 导出CSV
+
+ {{ row.details.originCity }} {{ row.details.destinationCode }} {{ row.details.distanceMiles }} {{ row.details.currency }}
+ {{ row.details.selfPickupPrice ?? '—' }} {{ row.details.fullDeliveryPrice ?? '—' }} {{ row.details.looseDeliveryPrice ?? '—' }}
+ {{ row.details.validFrom }} — {{ row.details.validTo }} {{ row.status === 'ACTIVE' ? '启用' : '停用' }}
+
+
+
diff --git a/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts b/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts
new file mode 100644
index 00000000..1c6a5e70
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts
@@ -0,0 +1,27 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
+import FleetQuotesView from './FleetQuotesView.vue'
+import TripOperationsView from './TripOperationsView.vue'
+import * as quotes from '../api/fleetQuotes'
+import * as reports from '../api/tripOperations'
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p.endsWith(':list') }) }))
+vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
+vi.mock('../api/fleetQuotes', () => ({ listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn(), exportFleetQuotes: vi.fn(), saveFleetCsv: vi.fn() }))
+vi.mock('../api/tripOperations', () => ({ listTripOperations: vi.fn(), exportTripOperations: vi.fn() }))
+const wrappers: VueWrapper[] = []
+const layout = { template: '
' }
+function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true,
+ DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '
' }, ElButton: { template: ' ' },
+ LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
+afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.clearAllMocks() })
+it('quotes read-only view fetches a page once and hides all write/export tools', async () => {
+ vi.mocked(quotes.listFleetQuotes).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
+ const wrapper = mount(FleetQuotesView); await flushPromises()
+ expect(quotes.listFleetQuotes).toHaveBeenCalledTimes(1); expect(wrapper.text()).not.toContain('新增报价'); expect(wrapper.text()).not.toContain('批量导入')
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')[0]).toMatchObject({ key: 'actions', fixed: 'left' })
+})
+it('operations uses server totals rather than displayed page size', async () => {
+ vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 21, page: 1, size: 20, pages: 2 }, summary: { trips: 21, inTransit: 12, completed: 9, physicalPallets: 40, palletSpaces: 40, ownPackages: 70, externalPackages: 10, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
+ const wrapper = mount(TripOperationsView); await flushPromises()
+ expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('实际出车 21 次'); expect(wrapper.text()).not.toContain('导出CSV')
+})
diff --git a/src/modules/overseas-master-data/pages/OverseasMasterDataMount.test.ts b/src/modules/overseas-master-data/pages/OverseasMasterDataMount.test.ts
index 7c683a8a..8f0d61a5 100644
--- a/src/modules/overseas-master-data/pages/OverseasMasterDataMount.test.ts
+++ b/src/modules/overseas-master-data/pages/OverseasMasterDataMount.test.ts
@@ -16,7 +16,7 @@ vi.mock('../api/overseasMasterData', () => ({
getExchangeRateSummary: vi.fn().mockResolvedValue({ latestMonth: null, latestUsdToCnyAverage: null, updatedBy: null, updatedAt: null }),
getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
listVehicles: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
- getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
+ getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
}))
@@ -37,5 +37,15 @@ describe('overseas master data real page mounts', () => {
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
})
- it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.get('.density-detail-title').text()).toBe('CA-TEST'); expect(wrapper.get('.density-fields').findAll('dt')).toHaveLength(11); expect(wrapper.get('.density-fields').text()).toContain('在役'); expect(wrapper.text()).not.toContain('IN_SERVICE'); expect(wrapper.text()).toContain('当前车辆主数据接口明确不包含派送履约记录') })
+ it('mounts vehicle detail and scopes both real fulfillment projections to the loaded vehicle', async () => {
+ const wrapper = mountView(VehicleDetailView); await flushPromises()
+ expect(wrapper.get('.density-detail-title').text()).toBe('CA-TEST')
+ expect(wrapper.get('.density-fields').findAll('dt')).toHaveLength(14)
+ expect(wrapper.get('.density-fields').text()).toContain('在役')
+ expect(wrapper.text()).not.toContain('IN_SERVICE')
+ const tables = wrapper.findAllComponents({ name: 'VehicleFulfillmentTable' })
+ expect(tables.map(table => table.props())).toEqual([
+ { vehicleId: 'VH-TEST-001', kind: 'deliveries' }, { vehicleId: 'VH-TEST-001', kind: 'changes' },
+ ])
+ })
})
diff --git a/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts b/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts
index 31f3aa59..bb5dc121 100644
--- a/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts
+++ b/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts
@@ -42,8 +42,8 @@ describe('overseas master data real contract pages', () => {
for (const label of ['自有车辆', '外派车辆', '司机 / 外派单位']) expect(vehicles).toContain(label)
expect(vehicles).toContain("key:'region'")
expect(vehicles).toContain("placeholder:'全部区域'")
- expect(vehicles).toContain('class="status-tabs density-tabs"')
- expect(vehicles).toContain("'status-tab',{active:ownership===tab[1]}")
+ expect(vehicles).toContain('class="status-tabs density-status-tabs"')
+ expect(vehicles).toContain("'status-tab',{'is-active':ownership===tab[1]}")
expect(vehicles).not.toContain(' {
expect(detail).toContain('派送订单明细')
expect(detail).toContain('派送更换记录')
expect(detail).toContain('getVehicle(')
- expect(detail).toContain('明确不包含派送履约记录')
+ expect(detail).toContain(':vehicle-id="detail.id" kind="deliveries"')
+ expect(detail).toContain(':vehicle-id="detail.id" kind="changes"')
+ expect(detail).not.toContain('车辆更换记录尚未接入')
expect(detail).not.toContain('AppDrawer')
expect(detail).not.toContain('const vehicle =')
})
@@ -85,7 +87,10 @@ describe('overseas master data real contract pages', () => {
const matrix = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
for (const id of ['gen-partner-warehouse', 'gen-overseas-quote', 'gen-exchange-rate', 'vehicle-detail']) expect(matrix).toContain(id)
for (const modal of ['modal-gen-overseas-quote-detail', 'modal-gen-overseas-quote', 'modal-exchange-rate-edit', 'modal-vehicle-edit']) expect(matrix).toContain(modal)
- expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
+ expect(matrix).toContain('旧版原型未定义的车辆文件预览、审核/确认流程不自行补充')
+ expect(matrix).toContain('新版车辆保养及加油接入矩阵')
+ expect(matrix).toContain('modal-vehicle-ops-record')
+ expect(matrix).toContain('target="_blank"')
})
it('records the real service boundary and remaining unsupported operations', () => {
diff --git a/src/modules/overseas-master-data/pages/OverseasMasterDataWriteBehavior.test.ts b/src/modules/overseas-master-data/pages/OverseasMasterDataWriteBehavior.test.ts
index e0f73299..8e50f86d 100644
--- a/src/modules/overseas-master-data/pages/OverseasMasterDataWriteBehavior.test.ts
+++ b/src/modules/overseas-master-data/pages/OverseasMasterDataWriteBehavior.test.ts
@@ -23,7 +23,7 @@ const longId = '9223372036854775807'
const quote = { id: longId, name: '真实报价', warehouseId: '9223372036854775806', warehouseName: '合作仓', warehouseCode: 'US-LAX', countryCode: 'US', providerName: 'Provider', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', status: 'INACTIVE' as const, updatedAt: '2026-08-24T10:00:00', updatedBy: 'admin', recordVersion: 7, remark: null }
const quoteDetail = { summary: quote, warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }], transitPrices: [], createdBy: 'admin', createdAt: '2026-08-24T09:00:00' }
const rate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: 7.12345678, status: 'ACTIVE' as const, recordVersion: 3, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
-const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, 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.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, remark: null, recordVersion: 4, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
const emptyPage = { records: [], page: 1, size: 20, total: 0, pages: 0 }
const slot = { template: '
' }
diff --git a/src/modules/overseas-master-data/pages/OverseasQuotesView.vue b/src/modules/overseas-master-data/pages/OverseasQuotesView.vue
index 49b13606..ff5017c0 100644
--- a/src/modules/overseas-master-data/pages/OverseasQuotesView.vue
+++ b/src/modules/overseas-master-data/pages/OverseasQuotesView.vue
@@ -54,11 +54,16 @@ async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
-
-
-
+
+
+
+ 海外仓报价
+
+
+
+
新增报价 导出CSV
- 详情 编辑 {{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}
+ 详情 编辑 {{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}
{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}
{{item[0]}} {{item[1]}} 仓储作业费用 中转派送价格
diff --git a/src/modules/overseas-master-data/pages/TripDetailView.vue b/src/modules/overseas-master-data/pages/TripDetailView.vue
new file mode 100644
index 00000000..1bb8c1e9
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/TripDetailView.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
{{ label }} {{ value || '—' }}
+ {{ detail.trip.stops.map(stop => stop.name).join(' → ') }}
{{ driverQualificationLabels[detail.driverQualification.reason] }} · 证照将在 30 天内到期,请留意任务延期
+
+ 🚚 分配与货物 📦 外接预订 📋 计划容量 📦 实际装载 ✅ 结案与任务结束 在途交接 直接成本 🕘 操作历史
+
+
+ 关联排车单 按货物匹配卸货站
+ 先分配车辆和司机,关联排车单并匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。
+ 详情 解除关联 审核通过 打回
{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }} {{ row.review.reason }}
{{ time(row.review.reviewedAt) }} {{ time(row.boundAt) }}
+
+
+
+
+
+
+ 理货审核:{{ tripReviewLabels[detail.loadingReview.status] }} · {{ detail.loadingReview.reason }} · {{ time(detail.loadingReview.reviewedAt) }}
理货审核通过 理货打回
+
+ 重新检查 确认货物结案
{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}
+ {{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }} {{ time(row.terminalAt) }}
+
+ 货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。
车辆任务 {{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}
司机任务 {{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}
+
+
+
+
+ {{ releaseRequest ? '重试原结束请求' : '确认结束资源任务' }}
+
+
+ {{ time(row.occurredAt) }} {{ actionLabels[row.action] || '更新记录' }}
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/pages/TripOperationsView.vue b/src/modules/overseas-master-data/pages/TripOperationsView.vue
new file mode 100644
index 00000000..477b427d
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/TripOperationsView.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+ 实际出车 {{ result.summary.trips }} 次 在途 {{ result.summary.inTransit }} 次 货物结案 {{ result.summary.completed }} 次
+ 实物托盘 {{ result.summary.physicalPallets }} 托 外接约定 {{ money(result.summary.externalAgreedAmounts) }}
+
+ 已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips }} 趟。
+ 按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。
+
+ 导出CSV
+ 详情 实测记录
+ {{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }} {{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}
+ {{ row.measured?.actualDistanceMiles ?? '—' }} {{ row.measured?.actualFuelLitres ?? '—' }}
+ {{ money(row.externalAgreedAmounts) }}
+ 无成本读取权限 未登记成本 {{ row.costs.totals.map((c: NonNullable['totals'][number]) => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') }}
+ — {{ row.costs.allocationBlockReason }} {{ row.costs.totals.map((c: NonNullable['totals'][number]) => `${c.currency} ${c.ownCost == null ? '—' : c.ownCost.toFixed(2)} / ${c.externalCost == null ? '—' : c.externalCost.toFixed(2)}`).join(';') || '—' }}
+
+
+
+
diff --git a/src/modules/overseas-master-data/pages/TripsBehavior.test.ts b/src/modules/overseas-master-data/pages/TripsBehavior.test.ts
new file mode 100644
index 00000000..d6b59f7a
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/TripsBehavior.test.ts
@@ -0,0 +1,168 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import TripsView from './TripsView.vue'
+import TripDetailView from './TripDetailView.vue'
+import TripPlanDialog from '../components/TripPlanDialog.vue'
+import TripLoadingPanel from '../components/TripLoadingPanel.vue'
+import * as api from '../api/trips'
+import type { FleetTrip, TripDetail, TripLoading } from '../tripTypes'
+
+const state = vi.hoisted(() => ({ writable: true, push: vi.fn() }))
+vi.mock('vue-router', () => ({ useRoute: () => ({ params: { tripNo: 'TR-TEST' } }), useRouter: () => ({ push: state.push }) }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || state.writable }) }))
+vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '请核对预约时间' }) } }))
+vi.mock('../api/drivers', () => ({ listDriverWarehouses: vi.fn().mockResolvedValue([{ id: '92', name: '海外仓', code: 'WH-US' }]) }))
+vi.mock('../api/trips', () => ({ listTrips: vi.fn(), getTrip: vi.fn(), createTrip: vi.fn(), updateTripPlan: vi.fn(), assignTrip: vi.fn(),
+ bindTripDispatch: vi.fn(), unbindTripDispatch: vi.fn(), cancelTrip: vi.fn(), getTripLoading: vi.fn(), confirmTripLoading: vi.fn(),
+ getTripCompletion: vi.fn(), completeTrip: vi.fn(), releaseTripResource: vi.fn(), listTripHistory: vi.fn(), reviewTripDispatch: vi.fn(), reviewTripLoading: vi.fn(), getTripCapacityPlan: vi.fn(), confirmTripCapacityPlan: vi.fn() }))
+const trip: FleetTrip = { tripNo: 'TR-TEST', status: 'PLANNED', warehouseId: '92', warehouseName: '海外仓', warehouseCode: 'WH-US', vehicleId: '71',
+ vehiclePlate: 'TEST-TRUCK', driverId: '81', driverName: '测试司机', driverPhone: '123', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
+ plannedDepartureAt: '2026-10-03T00:00:00Z', expectedDriverEndAt: '2026-10-04T00:00:00Z', departedAt: null, completedAt: null,
+ stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, recordVersion: 3, updatedAt: '2026-10-02T00:00:00Z', vehicleRelease: null, driverRelease: null }
+const reviewState = { status: 'PENDING' as const, reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
+const detail: TripDetail = { trip, driverQualification: null, dispatches: [], loadingReview: reviewState }
+const layout = { template: '
' }
+const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: ' ' }
+const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '保存计划
' }
+const table = { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page'], template: '' }
+const wrappers: VueWrapper[] = []
+function mount(component: object, props: Record = {}): VueWrapper {
+ const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
+ AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true,
+ ApiErrorMeta: true, LoadErrorState: true, AppDialog: dialog, TripPlanDialog: component === TripPlanDialog ? false : true,
+ TripAssignmentPanel: true, TripLoadingPanel: component === TripLoadingPanel ? false : true,
+ ElButton: { props: ['disabled', 'loading'], template: ' ' }, ElForm: layout, ElFormItem: layout,
+ ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: { ...input, name: 'ElDatePicker' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElOption: true,
+ ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '
' }, ElTabPane: true,
+ } } }); wrappers.push(wrapper); return wrapper
+}
+function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
+beforeEach(() => {
+ vi.clearAllMocks(); state.writable = true
+ vi.mocked(api.listTrips).mockResolvedValue({ records: [structuredClone(trip)], page: 1, size: 20, total: 1, pages: 1 })
+ vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail))
+ vi.mocked(api.listTripHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
+})
+afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
+
+it('shows mixed signature and return quantities from one lazy completion read', async () => {
+ vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), trip: { ...trip, status: 'IN_TRANSIT' } })
+ const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: 'A', destination: 'ONT8', status: 'DELIVERED',
+ packages: 12, signedPackages: 8, returnedPackages: 4, remainingPackages: 0, terminalAt: trip.updatedAt }
+ vi.mocked(api.getTripCompletion).mockResolvedValue({ tripNo: trip.tripNo, recordVersion: 3, canComplete: true, blockReason: null, outcomes: [outcome] })
+ const wrapper = mount(TripDetailView); await flushPromises()
+ expect(api.getTripCompletion).not.toHaveBeenCalled()
+ wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'completion'); await flushPromises()
+ expect(api.getTripCompletion).toHaveBeenCalledExactlyOnceWith('TR-TEST')
+ const result = wrapper.findComponent({ name: 'DataTable' })
+ expect(result.props('data')).toEqual([outcome])
+ expect(result.props('columns')).toEqual(expect.arrayContaining([
+ expect.objectContaining({ prop: 'packages', label: '原实发箱数' }),
+ expect.objectContaining({ prop: 'signedPackages', label: '实签箱数' }),
+ expect.objectContaining({ prop: 'returnedPackages', label: '本趟实退箱数' }),
+ expect.objectContaining({ prop: 'remainingPackages', label: '未结箱数' }),
+ ]))
+ expect(wrapper.text()).toContain('余货已签收')
+ expect(api.completeTrip).not.toHaveBeenCalled()
+})
+
+it('mounts direct costs only when the costs tab is selected', async () => {
+ const wrapper = mount(TripDetailView); await flushPromises()
+ expect(wrapper.findComponent({ name: 'TripCostPanel' }).exists()).toBe(false)
+ wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'costs'); await flushPromises()
+ expect(wrapper.findComponent({ name: 'TripCostPanel' }).props('tripNo')).toBe('TR-TEST')
+})
+
+it('loads one trip page and opens a real detail route without fetching each row', async () => {
+ const wrapper = mount(TripsView); await flushPromises()
+ expect(api.listTrips).toHaveBeenCalledTimes(1); expect(api.getTrip).not.toHaveBeenCalled()
+ await button(wrapper, '详情').trigger('click')
+ expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
+ wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
+ expect(api.listTrips).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
+})
+
+it('keeps read-only trip detail free of write actions and reads history only when opened', async () => {
+ state.writable = false
+ const wrapper = mount(TripDetailView); await flushPromises()
+ expect(api.getTrip).toHaveBeenCalledTimes(1); expect(api.listTripHistory).not.toHaveBeenCalled(); expect(api.getTripLoading).not.toHaveBeenCalled()
+ expect(wrapper.text()).not.toContain('编辑计划'); expect(wrapper.findComponent({ name: 'TripAssignmentPanel' }).exists()).toBe(false)
+ wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
+ expect(api.listTripHistory).toHaveBeenCalledExactlyOnceWith('TR-TEST', 1, 20)
+})
+
+it('keeps the exact create command for an uncertain retry and accepts optional notes', async () => {
+ vi.mocked(api.createTrip).mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(structuredClone(detail))
+ const wrapper = mount(TripPlanDialog, { modelValue: false, trip: null })
+ await wrapper.setProps({ modelValue: true }); await flushPromises()
+ wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '92'); await flushPromises()
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ const first = vi.mocked(api.createTrip).mock.calls[0]![0]
+ expect(first.idempotencyKey).toBeTruthy(); expect(first.remark).toBeNull()
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.createTrip).toHaveBeenNthCalledWith(2, first)
+ expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(detail)
+})
+
+it('counts a shared physical pallet once and submits only operator-entered measurements', async () => {
+ const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
+ const loading: TripLoading = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo, { ...cargo, dispatchNo: 'DP-B', customerId: 'B', dispatchItemId: '101' }], manifestHash: 'a'.repeat(64), confirmedLoad: null, blockReason: null }
+ vi.mocked(api.getTripLoading).mockResolvedValue(loading)
+ vi.mocked(api.confirmTripLoading).mockResolvedValue({ ...loading, recordVersion: 4 })
+ const wrapper = mount(TripLoadingPanel, { trip, editable: true }); await flushPromises()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toHaveLength(1)
+ await button(wrapper, '确认实际装载').trigger('click'); expect(api.confirmTripLoading).not.toHaveBeenCalled(); expect(ElMessage.warning).toHaveBeenCalled()
+ const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
+ fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 1); await flushPromises()
+ await button(wrapper, '确认实际装载').trigger('click'); await flushPromises()
+ expect(api.confirmTripLoading).toHaveBeenCalledExactlyOnceWith('TR-TEST', expect.objectContaining({ recordVersion: 3, manifestHash: loading.manifestHash,
+ pallets: [{ warehousePalletId: '901', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] }))
+})
+
+it('submits a specific dispatch review with optional approval notes and a reason when rejecting', async () => {
+ const pending: TripDetail = { ...structuredClone(detail), dispatches: [{ dispatchNo: 'DP-A', customerId: 'A', packageCount: 10, boundBy: '1', boundAt: trip.updatedAt, review: reviewState }] }
+ vi.mocked(api.getTrip).mockResolvedValue(pending)
+ vi.mocked(api.reviewTripDispatch).mockResolvedValue({ ...pending, trip: { ...trip, recordVersion: 4 } })
+ const wrapper = mount(TripDetailView); await flushPromises()
+ await button(wrapper, '审核通过').trigger('click'); await flushPromises()
+ expect(api.reviewTripDispatch).toHaveBeenCalledWith('TR-TEST', 'DP-A', { recordVersion: 3, decision: 'APPROVED', reason: null })
+ await button(wrapper, '打回').trigger('click'); await flushPromises()
+ expect(ElMessageBox.prompt).toHaveBeenCalled()
+ expect(api.reviewTripDispatch).toHaveBeenLastCalledWith('TR-TEST', 'DP-A', { recordVersion: 4, decision: 'REJECTED', reason: '请核对预约时间' })
+})
+
+it('clears displayed loading approval after new actual load and uses the returned version for review', async () => {
+ vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), loadingReview: { ...reviewState, status: 'APPROVED' } })
+ vi.mocked(api.reviewTripLoading).mockResolvedValue(structuredClone(detail))
+ const wrapper = mount(TripDetailView); await flushPromises()
+ wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'loading'); await flushPromises()
+ expect(button(wrapper, '理货审核通过').attributes('disabled')).toBeDefined()
+ wrapper.findComponent({ name: 'TripLoadingPanel' }).vm.$emit('saved', { tripNo: trip.tripNo, recordVersion: 7 }); await flushPromises()
+ expect(button(wrapper, '理货审核通过').attributes('disabled')).toBeUndefined()
+ await button(wrapper, '理货审核通过').trigger('click'); await flushPromises()
+ expect(api.reviewTripLoading).toHaveBeenCalledWith('TR-TEST', { recordVersion: 7, decision: 'APPROVED', reason: null })
+})
+
+it('lets an operator plan capacity before picking without confirming actual loading', async () => {
+ const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
+ const basis = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo], manifestHash: 'a'.repeat(64), capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
+ vi.mocked(api.getTripCapacityPlan).mockResolvedValue(basis)
+ vi.mocked(api.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 4, planValid: true })
+ const wrapper = mount(TripLoadingPanel, { trip, editable: true, planning: true }); await flushPromises()
+ expect(api.getTripLoading).not.toHaveBeenCalled()
+ const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
+ fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 1); await flushPromises()
+ await button(wrapper, '确认计划容量').trigger('click'); await flushPromises()
+ expect(api.confirmTripCapacityPlan).toHaveBeenCalledOnce(); expect(api.confirmTripLoading).not.toHaveBeenCalled()
+ expect(wrapper.emitted('planned')?.[0]?.[0]).toMatchObject({ recordVersion: 4 })
+ expect(wrapper.emitted('saved')).toBeUndefined()
+})
+
+it('refreshes trip members and loading review after external dispatch binding without leaving the current tab', async () => {
+ const wrapper = mount(TripDetailView); await flushPromises()
+ wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'bookings'); await flushPromises()
+ wrapper.findComponent({ name: 'TripBookingPanel' }).vm.$emit('linked'); await flushPromises()
+ expect(api.getTrip).toHaveBeenCalledTimes(2)
+ expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('bookings')
+})
diff --git a/src/modules/overseas-master-data/pages/TripsView.vue b/src/modules/overseas-master-data/pages/TripsView.vue
new file mode 100644
index 00000000..6ade0057
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/TripsView.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+ 新建车次
+ 详情
+ {{ tripStatusLabels[row.status as keyof typeof tripStatusLabels] }}
+ {{ time(row.plannedDepartureAt) }} {{ time(row.expectedDriverEndAt) }}
+ {{ row.stops.map((stop: FleetTrip['stops'][number]) => stop.name).join(' → ') }}
+
+
+
+
diff --git a/src/modules/overseas-master-data/pages/VehicleDetailView.vue b/src/modules/overseas-master-data/pages/VehicleDetailView.vue
index 96a819de..f84e0b54 100644
--- a/src/modules/overseas-master-data/pages/VehicleDetailView.vue
+++ b/src/modules/overseas-master-data/pages/VehicleDetailView.vue
@@ -1,6 +1,9 @@
-
{{item[0]}} {{item[1]}} 当前车辆主数据接口明确不包含派送履约记录。
当前车辆主数据接口明确不包含车辆更换历史。
-
+
+
diff --git a/src/modules/overseas-master-data/pages/VehiclesQueryRace.test.ts b/src/modules/overseas-master-data/pages/VehiclesQueryRace.test.ts
new file mode 100644
index 00000000..be8e6507
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/VehiclesQueryRace.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
+import VehiclesView from './VehiclesView.vue'
+import * as api from '../api/overseasMasterData'
+
+vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn(), getVehicle: vi.fn(), createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn() }))
+const layout = { template: '
' }
+const table = { name: 'DataTable', props: ['data', 'total', 'loading'], template: '' }
+type Page = Awaited>
+function deferred() {
+ let resolve!: (page: Page) => void, reject!: (reason: Error) => void
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no })
+ return { promise, resolve, reject }
+}
+function page(plateNumber: string): Page {
+ return { records: [{ id: plateNumber, plateNumber } as Page['records'][number]], total: 1, page: 1, size: 20, pages: 1 }
+}
+const wrappers: VueWrapper[] = []
+function mount() {
+ const wrapper = shallowMount(VehiclesView, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, DataTable: table, QueryToolbar: true, LoadErrorState: true, AppDialog: true } } })
+ wrappers.push(wrapper); return wrapper
+}
+afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.clearAllMocks() })
+
+it.each(['success', 'failure'] as const)('keeps the selected ownership results after a stale %s', async outcome => {
+ const old = deferred(), current = deferred()
+ vi.mocked(api.listVehicles).mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise)
+ const wrapper = mount()
+ await wrapper.get('[role="tab"][aria-selected="false"]').trigger('click')
+ expect(api.listVehicles).toHaveBeenLastCalledWith(expect.objectContaining({ ownership: 'DISPATCHED' }))
+ current.resolve(page('EXTERNAL-CURRENT')); await flushPromises()
+ if (outcome === 'success') old.resolve(page('OWNED-STALE'))
+ else old.reject(new Error('old request failed'))
+ await flushPromises()
+ expect(wrapper.text()).toContain('EXTERNAL-CURRENT')
+ expect(wrapper.text()).not.toContain('OWNED-STALE')
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(1)
+})
+
+it('keeps loading until the currently selected query finishes', async () => {
+ const old = deferred(), current = deferred()
+ vi.mocked(api.listVehicles).mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise)
+ const wrapper = mount()
+ await wrapper.get('[role="tab"][aria-selected="false"]').trigger('click')
+ old.resolve(page('OWNED-STALE')); await flushPromises()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('loading')).toBe(true)
+ expect(wrapper.text()).not.toContain('OWNED-STALE')
+ current.resolve(page('EXTERNAL-CURRENT')); await flushPromises()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('loading')).toBe(false)
+ expect(api.listVehicles).toHaveBeenCalledTimes(2)
+})
+
+it('opens a new vehicle with unknown fuel consumption blank instead of clamping it to 0.01', async () => {
+ vi.mocked(api.listVehicles).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
+ const wrapper = shallowMount(VehiclesView, { global: { stubs: {
+ AdminPage: layout, ListPageLayout: layout, QueryToolbar: true, LoadErrorState: true,
+ DataTable: { ...table, template: '
' },
+ AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '保存
' },
+ ElButton: { template: ' ' }, ElForm: layout, ElFormItem: layout,
+ ElInputNumber: { name: 'ElInputNumber', props: ['modelValue'], template: ' ' }, ElInput: true, ElSelect: true, ElOption: true, ElTag: true,
+ } } })
+ wrappers.push(wrapper); await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '添加车辆')!.trigger('click')
+ await flushPromises()
+ expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.get('input').element.value).toBe('')
+ await wrapper.findAll('button').find(button => button.text() === '保存')!.trigger('click')
+ await flushPromises()
+ expect(api.createVehicle).not.toHaveBeenCalled()
+})
diff --git a/src/modules/overseas-master-data/pages/VehiclesView.vue b/src/modules/overseas-master-data/pages/VehiclesView.vue
index da44e5a5..97952a1e 100644
--- a/src/modules/overseas-master-data/pages/VehiclesView.vue
+++ b/src/modules/overseas-master-data/pages/VehiclesView.vue
@@ -1,7 +1,7 @@
-{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }} {{tab[0]}}
添加车辆 导出CSV查看 编辑 状态
{{vehicleStatusLabels[(row as VehicleDetail).status]}} {{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}
-
-
+
+ 车辆列表
+ {{ tab[1] === 'OWNED' ? '🚚' : '🚛' }} {{tab[0]}}
+
+
+ 添加车辆 导出CSV查看 编辑 状态
{{vehicleStatusLabels[(row as VehicleDetail).status]}} {{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}
+
+
diff --git a/src/modules/overseas-master-data/permissions.ts b/src/modules/overseas-master-data/permissions.ts
index b9cae1c4..7b3cad05 100644
--- a/src/modules/overseas-master-data/permissions.ts
+++ b/src/modules/overseas-master-data/permissions.ts
@@ -1,4 +1,36 @@
export const overseasMasterDataPermissions = {
+ fleetQuoteList: 'platform:fleet:quote:list',
+ fleetQuoteCreate: 'platform:fleet:quote:create',
+ fleetQuoteEdit: 'platform:fleet:quote:edit',
+ fleetQuoteStatus: 'platform:fleet:quote:status',
+ fleetQuoteDelete: 'platform:fleet:quote:delete',
+ fleetQuoteImport: 'platform:fleet:quote:import',
+ fleetQuoteExport: 'platform:fleet:quote:export',
+ operationsList: 'platform:fleet:operations:list',
+ operationsExport: 'platform:fleet:operations:export',
+ operationsEdit: 'platform:fleet:operations:edit',
+ tripBookingCreate: 'platform:fleet:trip:booking-create',
+ tripBookingEdit: 'platform:fleet:trip:booking-edit',
+ tripBookingConfirm: 'platform:fleet:trip:booking-confirm',
+ tripBookingCancel: 'platform:fleet:trip:booking-cancel',
+ tripList: 'platform:fleet:trip:list',
+ tripCreate: 'platform:fleet:trip:create',
+ tripEdit: 'platform:fleet:trip:edit',
+ tripCostCreate: 'platform:fleet:trip:cost-create',
+ tripCostEdit: 'platform:fleet:trip:cost-edit',
+ tripAssign: 'platform:fleet:trip:assign',
+ tripHandover: 'platform:fleet:trip:handover',
+ tripCancel: 'platform:fleet:trip:cancel',
+ tripComplete: 'platform:fleet:trip:complete',
+ tripRelease: 'platform:fleet:trip:release',
+ tripDispatchReview: 'platform:fleet:trip:dispatch-review',
+ tripLoadingReview: 'platform:fleet:trip:loading-review',
+ driverList: 'platform:overseas-master-data:driver:list',
+ driverCreate: 'platform:overseas-master-data:driver:create',
+ driverEdit: 'platform:overseas-master-data:driver:edit',
+ driverStatus: 'platform:overseas-master-data:driver:status',
+ driverReview: 'platform:overseas-master-data:driver:review',
+ driverUpload: 'platform:overseas-master-data:driver:upload',
quoteList: 'platform:overseas-master-data:quote:list',
quoteCreate: 'platform:overseas-master-data:quote:create',
quoteEdit: 'platform:overseas-master-data:quote:edit',
@@ -14,5 +46,9 @@ export const overseasMasterDataPermissions = {
vehicleEdit: 'platform:overseas-master-data:vehicle:edit',
vehicleStatus: 'platform:overseas-master-data:vehicle:status',
vehicleExport: 'platform:overseas-master-data:vehicle:export',
+ vehicleRecordCreate: 'platform:overseas-master-data:vehicle:record-create',
+ vehicleRecordEdit: 'platform:overseas-master-data:vehicle:record-edit',
+ vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload',
+ vehicleRecordFinanceSubmit: 'platform:overseas-master-data:vehicle:record-finance-submit',
} as const
diff --git a/src/modules/overseas-master-data/routes.test.ts b/src/modules/overseas-master-data/routes.test.ts
index 47738eb7..80f22766 100644
--- a/src/modules/overseas-master-data/routes.test.ts
+++ b/src/modules/overseas-master-data/routes.test.ts
@@ -7,6 +7,12 @@ import { overseasMasterDataRoutes } from './routes'
describe('overseas master data routes', () => {
it('uses the frozen stable paths and list permissions', () => {
expect(overseasMasterDataRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
+ ['overseas-master-data/fleet-quotes', overseasMasterDataPermissions.fleetQuoteList],
+ ['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList],
+ ['overseas-master-data/trips', overseasMasterDataPermissions.tripList],
+ ['overseas-master-data/trips/:tripNo', overseasMasterDataPermissions.tripList],
+ ['overseas-master-data/drivers', overseasMasterDataPermissions.driverList],
+ ['overseas-master-data/drivers/:driverId', overseasMasterDataPermissions.driverList],
['overseas-master-data/quotes', overseasMasterDataPermissions.quoteList],
['overseas-master-data/exchange-rates', overseasMasterDataPermissions.exchangeRateList],
['overseas-master-data/vehicles', overseasMasterDataPermissions.vehicleList],
diff --git a/src/modules/overseas-master-data/routes.ts b/src/modules/overseas-master-data/routes.ts
index abc8c418..6a8dc871 100644
--- a/src/modules/overseas-master-data/routes.ts
+++ b/src/modules/overseas-master-data/routes.ts
@@ -2,29 +2,65 @@ import type { RouteRecordRaw } from 'vue-router'
import { overseasMasterDataPermissions as permission } from './permissions'
export const overseasMasterDataRoutes = [
+ {
+ path: 'overseas-master-data/fleet-quotes',
+ name: 'overseas-master-data-fleet-quotes',
+ component: () => import('./pages/FleetQuotesView.vue'),
+ meta: { title: '车队报价', description: '维护车队采购与外接销售报价', permission: permission.fleetQuoteList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-master-data/trip-operations',
+ name: 'overseas-master-data-trip-operations',
+ component: () => import('./pages/TripOperationsView.vue'),
+ meta: { title: '出车记录', description: '按实际发车记录查询车辆运营统计', permission: permission.operationsList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-master-data/trips',
+ name: 'overseas-master-data-trips',
+ component: () => import('./pages/TripsView.vue'),
+ meta: { title: '车次管理', description: '安排车辆司机、关联货物并核对整车装载', permission: permission.tripList },
+ },
+ {
+ path: 'overseas-master-data/trips/:tripNo',
+ name: 'overseas-master-data-trip-detail',
+ component: () => import('./pages/TripDetailView.vue'),
+ meta: { title: '车次详情', description: '分配、装载、结案与资源任务结束', permission: permission.tripList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-master-data/drivers',
+ name: 'overseas-master-data-drivers',
+ component: () => import('./pages/DriversView.vue'),
+ meta: { title: '司机管理', description: '维护司机档案、驾驶证及到期提醒', permission: permission.driverList },
+ },
+ {
+ path: 'overseas-master-data/drivers/:driverId',
+ name: 'overseas-master-data-driver-detail',
+ component: () => import('./pages/DriverDetailView.vue'),
+ meta: { title: '司机详情', description: '司机档案、证照审核与任务资格校验', permission: permission.driverList, showPageHeading: false },
+ },
{
path: 'overseas-master-data/quotes',
name: 'overseas-master-data-quotes',
component: () => import('./pages/OverseasQuotesView.vue'),
- meta: { title: '海外仓报价', description: '维护海外合作仓仓储作业与中转派送报价', permission: permission.quoteList },
+ meta: { showPageHeading: false, title: '海外仓报价', description: '维护海外合作仓仓储作业与中转派送报价', permission: permission.quoteList },
},
{
path: 'overseas-master-data/exchange-rates',
name: 'overseas-master-data-exchange-rates',
component: () => import('./pages/ExchangeRatesView.vue'),
- meta: { title: '汇率管理', description: '维护美元兑人民币月平均汇率', permission: permission.exchangeRateList },
+ meta: { showPageHeading: false, title: '汇率管理', description: '维护美元兑人民币月平均汇率', permission: permission.exchangeRateList },
},
{
path: 'overseas-master-data/vehicles',
name: 'overseas-master-data-vehicles',
component: () => import('./pages/VehiclesView.vue'),
- meta: { title: '车辆列表', description: '查询国内与海外自有、外派车辆档案', permission: permission.vehicleList },
+ meta: { showPageHeading: false, title: '车辆列表', description: '查询国内与海外自有、外派车辆档案', permission: permission.vehicleList },
},
{
path: 'overseas-master-data/vehicles/:vehicleId',
name: 'overseas-master-data-vehicle-detail',
component: () => import('./pages/VehicleDetailView.vue'),
- meta: { title: '车辆详情', description: '查看车辆档案、派送订单与更换记录', permission: permission.vehicleList, showPageHeading: false },
+ meta: { title: '车辆详情', description: '查看车辆档案、保养加油及车辆运维记录', permission: permission.vehicleList, showPageHeading: false },
},
] satisfies RouteRecordRaw[]
diff --git a/src/modules/overseas-master-data/tripTypes.ts b/src/modules/overseas-master-data/tripTypes.ts
new file mode 100644
index 00000000..027a2d46
--- /dev/null
+++ b/src/modules/overseas-master-data/tripTypes.ts
@@ -0,0 +1,66 @@
+import type { TripBooking } from './bookingTypes'
+import type { DriverQualification } from './driverTypes'
+
+export type TripStatus = 'PLANNED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
+export const tripStatusLabels: Record = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
+export interface TripStop { name: string; address?: string | null; destinationKey?: string | null }
+export type ReleasedVehicleStatus = 'IN_SERVICE' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
+export interface TripReleaseInfo { actualEndAt: string; endLocation: string; vehicleStatus: ReleasedVehicleStatus | null }
+export interface FleetTrip {
+ tripNo: string
+ status: TripStatus
+ warehouseId: string
+ warehouseCode: string
+ warehouseName: string
+ vehicleId: string | null
+ vehiclePlate: string | null
+ driverId: string | null
+ driverName: string | null
+ driverPhone: string | null
+ maxPalletSpaces: number | null
+ maxLoadKg: number | null
+ maxVolumeCbm: number | null
+ plannedDepartureAt: string
+ expectedDriverEndAt: string
+ departedAt: string | null
+ completedAt: string | null
+ stops: TripStop[]
+ remark: string | null
+ recordVersion: number
+ updatedAt: string
+ vehicleRelease: TripReleaseInfo | null
+ driverRelease: TripReleaseInfo | null
+}
+export interface TripReviewState { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewerId: string | null; reviewedAt: string | null; recordVersion: number | null }
+export interface TripReviewCommand { recordVersion: number; decision: 'APPROVED' | 'REJECTED'; reason?: string | null }
+export const tripReviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' } as const
+export interface TripBinding { dispatchNo: string; customerId: string; packageCount: number; boundBy: string; boundAt: string; review: TripReviewState }
+export interface TripDetail { trip: FleetTrip; driverQualification: DriverQualification | null; dispatches: TripBinding[]; loadingReview: TripReviewState }
+export interface TripDraft { warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; stops: TripStop[]; remark?: string | null; idempotencyKey: string }
+export interface TripPlan { recordVersion: number; plannedDepartureAt: string; expectedDriverEndAt: string; stops: TripStop[]; remark?: string | null; reason: string }
+export interface TripAssignment { recordVersion: number; vehicleId: string; driverId: string; reason: string }
+export interface TripBindingCommand { recordVersion: number; dispatchNo: string; reason: string }
+export interface TripCargo {
+ dispatchNo: string; customerId: string; dispatchItemId: string; warehousePalletId: string | null
+ palletNo: string | null; packages: number; destinationKey: string | null; destinationLabel: string | null
+}
+export interface TripMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
+export interface TripLoad { palletSpaces: number; weightKg: number; volumeCbm: number }
+export interface TripSegment { fromIndex: number; toIndex: number; occupied: TripLoad; remaining: TripLoad }
+export interface TripConfirmedLoad { manifestHash: string; cargo: TripCargo[]; pallets: TripMeasurement[]; segments: TripSegment[]; operatorId: string; confirmedAt: string }
+export interface TripPlannedLoad { manifestHash: string; cargo: TripCargo[]; pallets: TripMeasurement[]; segments: TripSegment[]; operatorId: string; confirmedAt: string }
+export interface TripCapacityPlanning { tripNo: string; recordVersion: number; cargo: TripCargo[]; manifestHash: string; capacityPlan: TripPlannedLoad | null; planValid: boolean; blockReason: string | null; availableSegments: TripSegment[] }
+export interface TripLoading { tripNo: string; recordVersion: number; cargo: TripCargo[]; manifestHash: string; confirmedLoad: TripConfirmedLoad | null; blockReason: string | null }
+export interface TripLoadConfirmation { recordVersion: number; manifestHash: string; pallets: TripMeasurement[]; reason: string }
+export interface TripOutcome { dispatchNo: string; deliveryNo: string; customerId: string; destination: string | null; status: string; packages: number; signedPackages: number; returnedPackages: number; remainingPackages: number; terminalAt: string | null }
+export interface TripCompletionInspection { tripNo: string; recordVersion: number; canComplete: boolean; blockReason: string | null; outcomes: TripOutcome[] }
+export interface TripComplete { recordVersion: number; idempotencyKey: string; reason: string }
+export interface TripResourceRelease {
+ recordVersion: number; resourceKind: 'VEHICLE' | 'DRIVER'; actualEndAt: string; endLocation: string
+ vehicleStatus?: ReleasedVehicleStatus | null; idempotencyKey: string; reason: string
+}
+export interface TripCompletionEvidence { idempotencyKey: string; requestHash: string; completedAt: string; recordedAt: string; operatorId: string; outcomes: TripOutcome[] }
+export interface TripHistory {
+ recordVersion: number; action: string; reason: string | null; operatorId: string; occurredAt: string
+ snapshot: { externalBookings: TripBooking[]; capacityPlan: TripPlannedLoad | null; trip: FleetTrip; dispatches: TripBinding[]; confirmedLoad: TripConfirmedLoad | null; completion: TripCompletionEvidence | null; loadingReview: TripReviewState }
+}
diff --git a/src/modules/overseas-master-data/types.ts b/src/modules/overseas-master-data/types.ts
index 4e0847f5..a13814da 100644
--- a/src/modules/overseas-master-data/types.ts
+++ b/src/modules/overseas-master-data/types.ts
@@ -55,6 +55,9 @@ export interface MonthlyExchangeRate {
}
export interface VehicleSummary {
+ maxPalletSpaces: number | null
+ maxLoadKg: number | null
+ maxVolumeCbm: number | null
id: string
plateNumber: string
model: string
diff --git a/src/modules/overseas-master-data/vehicleCareTypes.ts b/src/modules/overseas-master-data/vehicleCareTypes.ts
new file mode 100644
index 00000000..202a5431
--- /dev/null
+++ b/src/modules/overseas-master-data/vehicleCareTypes.ts
@@ -0,0 +1,41 @@
+export type VehicleCareType = 'MAINTENANCE' | 'FUEL' | 'REPAIR' | 'BREAKDOWN' | 'RESCUE' | 'EXCEPTION'
+export interface VehicleIncident {
+ description: string
+ item: string | null
+ place: string | null
+ handler: string | null
+ processingStatus: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'
+ exceptionType: 'ROUTE' | 'TIMEOUT' | 'DELIVERY' | 'VEHICLE_BREAKDOWN' | null
+ reportedCost: number | null
+ currency: string | null
+}
+export interface VehicleCareDraft {
+ recordType: VehicleCareType
+ occurredAt: string
+ distanceUnit: 'KM' | 'MI' | null
+ odometer: number | null
+ lastServiceOdometer: number | null
+ nextServiceOdometer: number | null
+ fuelQuantity: number | null
+ fuelUnit: 'L' | 'US_GAL' | 'IMP_GAL' | null
+ verificationStatus: 'PENDING' | 'VERIFIED' | null
+ remark: string | null
+ incident: VehicleIncident | null
+}
+export interface VehicleCareFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
+export interface VehicleCareRecord {
+ recordNo: string
+ vehicleId: string
+ vehiclePlate: string
+ details: VehicleCareDraft
+ files: VehicleCareFile[]
+ recordVersion: number
+ updatedBy: string
+ updatedAt: string
+ paymentStatus: 'UNLINKED' | null
+}
+export const distanceLabels = { KM: 'km', MI: 'mi' } as const
+export const fuelLabels = { L: 'L', US_GAL: '美制 gal', IMP_GAL: '英制 gal' } as const
+export const vehicleCareLabels: Record = { MAINTENANCE: '保养记录', FUEL: '加油记录', REPAIR: '维修记录', BREAKDOWN: '紧急故障', RESCUE: '应急救援', EXCEPTION: '异常管理' }
+export const processingLabels = { PENDING: '待处理', IN_PROGRESS: '处理中', COMPLETED: '已完成' } as const
+export const exceptionLabels = { ROUTE: '轨迹异常', TIMEOUT: '时间超时异常', DELIVERY: '派送异常', VEHICLE_BREAKDOWN: '车辆故障异常' } as const
diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md
index caa948ee..b87513a5 100644
--- a/src/modules/overseas-warehouse/AGENTS.md
+++ b/src/modules/overseas-warehouse/AGENTS.md
@@ -9,7 +9,7 @@
- 本文件适用于 `src/modules/overseas-warehouse/`,路径默认相对此目录;其他模块以 `../` 开头。应用公共规则见 `../../../AGENTS.md`。
- 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;页面是否开放以正式路由、权限和测试为准。后台接口与状态进入 `../../../../../baiyue-firstleg/lastleg-service/AGENTS.md`,不扩大扫描其他业务模块。
-- 请求统一使用 `../../shared/api/overseasHttp.ts`;不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替代码与服务端契约。
+- 请求统一使用 `../../shared/api/http.ts`;不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替代码与服务端契约。
## 页面与业务快速索引
@@ -43,6 +43,8 @@
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
+- 排车转派送弹框打开时并行读取一次`getDispatchExternalFreight`和`getDispatchFeeCoverage`;不逐行查费用。`DeliveryFeeCoveragePanel`按实际各票原费用明确勾选派送/装车/买约,提交来源ID/版本/hash与货物摘要,不按金额推断覆盖。`fullyCoveredServices`只控制输入展示,服务端重新核验;只有全部货物已包含的项目才不提交新增应收,部分覆盖仍需填剩余项目。外接报价只读并提交externalFreightHash。派送详情显示确认时的原费用覆盖快照,原费用金额与本次新增费用分开展示;不能将原费用再次记入新费用行。
+- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md
index e00781c4..70c9b4d2 100644
--- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md
+++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md
@@ -14,8 +14,8 @@
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
| FBA 预约详情/编辑 | `GET /fba-appointments/{appointmentNo}?customerId=...`;`PUT` 同 path | PUT body:`customerId,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | GET 同 list;PUT 同 appointment;失败保留弹框和错误元数据 |
| 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 |
-| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?` | 同 inbound list;仅发 `PLAN/RECEIVED` |
-| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId,draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;不调用编辑、取消、depart、PDA 或派送 |
+| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?`;退回候选返回`sourceType=RETURN_RECEIPT,returnReceiptId,returnReceiptNo`,箱数为实际剩余可拣量 | 同 inbound list;已实收阶段包含真实退回来源,展示业务凭据号;草稿恢复失败保留选择并允许重试 |
+| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId`或`sourceType=RETURN_RECEIPT,returnReceiptId`,及`draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],returnReceiptIds?:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;任何已选来源未恢复时禁止提交,不能过滤后静默少发;不调用编辑、取消、depart、PDA 或派送 |
| 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户;列表/详情客户只显示 `customerName`,海外仓只显示 `overseasWarehouseName`,空值为 `—` | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship;不以客户 ID、本地客户候选、国内来源仓或目的仓猜名称 |
| 库存位置/进销存 | `GET /inventory/balances/{balanceId}/positions`;`GET /inventory/ledger` | position:`palletNo?,batchNo?,inventoryStatus?`;ledger 含 `page,size,skuCode` 与业务单/仓库/时间筛选 | 同 inventory list;`balanceId` 编码;只展示后端事实 |
| 公共海外收货仓候选(国内 HTTP) | `GET /platform/v1/warehouse-admin` | `tenantId=0,page=1,size=200,capabilityCode=RECEIVING,warehouseType=OVERSEAS,status=ACTIVE` | 国内 Warehouse 是唯一仓库主数据源;托盘、库存和换标复用公共候选并保留字符串 ID,不请求海外仓库根路径 |
@@ -32,3 +32,5 @@
- 托盘原型操作列仅查看、编辑,工具栏仅新增;后端即使存在 DELETE,Admin 也不暴露。所有业务表格操作列保持第一列并固定左侧。
- 箱号和产品库不设独立正式 routes;海外出库、退货出库、海外派送、PDA、卸柜照片和库存离仓命令均按海外仓全流程的正式页面与接口归属验收,内部事件仍只作为服务端协作事实。
- 1920×1080、浏览器 100% 的人工截图仍待验收。
+
+| 异常详情及处理 | `GET /exception-orders/{exceptionNo}`;`POST /{exceptionNo}/start-processing`、`/complete`、`/cancel`、`/evidence`(均在 `/platform/v1/last-leg/exception-orders` 下) | 显式customerId;命令携带expectedVersion和原idempotencyKey;complete携证据及服务端冻结来源对应本批实收、真实目标托盘/库位;分批后重新读取剩余量;version/数量规范化为安全整数,业务ID保留字符串;历史证据不自动提交,本批选用1–20份;目标采用一次托盘详情+一次`GET /platform/v1/overseas-warehouse-resources/{warehouseId}/locations/{id}`核对,不遍历全仓;普通说明选填并提交标准审计说明 | 详情沿list;处理及上传均 `platform:overseas-warehouse:exception-order:process`;既有居中详情内操作,未知结果锁定原载荷仅重试原键,明确版本拒绝刷新;无目标资源读取权限时显示错误,不猜库位 |
diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md
index f726c931..3d276af2 100644
--- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md
+++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md
@@ -1,9 +1,19 @@
# 海外仓原型核对矩阵
+### F33 已有库存转运履约接入(2026-10-04)
+
+| 页面 | 操作 | 依据 | 承载方式与本轮结论 | 验收 |
+|---|---|---|---|---|
+| 尾程订单详情 | 本票转运、换标状态和已确认分配只读 | 原型 `03-overseas-warehouse.html:927` 的独立详情;阶段五9.1–9.4;本轮明确复用现有详情 | 在现有计划信息页签按需读取本票 `sourceOrderNo`,不增加页面;缺来源单号失败关闭,不以尾程号/内部ID替代 | 定向行为检查;1920×1080待人工截图确认 |
+| 尾程订单详情 | 查看关联换标/PDA进度 | 本轮要求沿本票关联换标;现有 `overseas-relabel-order-detail` | 使用返回的 `relabelNo` 进入既有独立详情;客户确认前明确禁止提前出库,不提供跳过按钮 | 定向权限/状态检查;待匹配后端发布及真实页面 |
+| 尾程订单详情 | 库存来源选择/分配 | 本轮明确复用详情/居中表单;当前 Allocate DTO;总控冻结逐绑定来源→分页→代表余额详情→位置的读取链 | 1320px居中表单,只确认客户/操作仓/绑定入仓/具体旧SKU/位置与托盘;歧义待核查不误报零库存。OCCUPIED不作不可用门禁,available=0及MIXED禁选;数量合计本票箱数。未知结果原键原内容重试;明确业务拒绝提供“重新选择来源”,保留上次诊断并重查原票/候选后确认新命令,不改写原命令 | 定向接口/数量/权限/失败重放与拒绝恢复检查;待匹配后端及真实页面取证 |
+
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/03-overseas-warehouse.html`。
当前范围按海外仓全流程维护。箱号管理、产品库不设独立正式路由,其数据通过入仓、箱唛、SKU 与库存页面承载;正式范围包含尾程订单、入库单、换标、转运、退货、托盘、库存、排车、出库、派送、异常退回、客户回访和一件代发。
+废弃审核补充:`page-last-mile-orders` 原型未定义废弃审核弹框。依当前对话“你确认就好了”的常规交互决策授权,沿用本页 `reviewReturn` / `reviewDirectRelabel` 的居中确认框,不新增路由或抽屉。只对有 `lastleg:cargo-discard:approve` 权限的待审核废弃行提供审核;框内显示废弃申请号,通过或驳回,驳回原因必填且最多 1000 字,关闭不提交,处理期间禁止重复点击。仅提交审核结论,不增加扣库命令或声明扣库时点。详情仍走本页原独立详情路由。本段定义承载方式,部署及验收状态只记录在测试环境主契约。
+
| 页面 | 操作 | 原型证据 | 原型承载方式 | 当前实现(开工前) | 冻结结论 |
|---|---|---|---|---|---|
| 尾程订单 | 列表/筛选 | `page-last-mile-orders`,868–897 行;`lastMileSearch()`、`lastMileReset()` | 独立列表页,状态标签、查询区、横向宽表 | `/prototype/overseas-warehouse-orders/*` 占位 | 建立正式列表路由;操作列第一列,保留原型字段顺序,接口未接时显示明确契约态 |
@@ -57,3 +67,33 @@
| 进销存记录 | 导出 | 页面 989 行“导出Excel”按钮 | 工具栏动作,原型未定义导出任务载体 | File 导出类型与海外接口未冻结,本批不显示可成功导出入口 |
`overseas-inbound`、`overseas-relabel` 等原型库存视图用于联查入仓/换标库存事实,不新增重复菜单页;当前正式入库单、换标单、库存列表与详情已经分别承载对应入口。`purchase-stock*` 和 `fba-return-stock` 是否作为独立正式页面尚无本轮导航与接口契约,不在本批自行扩展。
+
+
+### 车辆外接运费增量(2026-10-03)
+
+| 页面 | 操作 | 依据 | 承载方式 | 本次变更 | 验收 |
+|---|---|---|---|---|---|
+| 排车列表 | 转派送费用核对 | 用户新版原型12374行1120px居中弹框、12404行ove-panel-delivery费用分区;当前对话确认原费用覆盖、新增项目收费 | 保留现有1120px三列居中转派送弹框 | 费用分区带出外接实收报价,全部外接不重复填写派送应收;无新增抽屉或页面 | 本地交互测试通过,1920×1080实际页面待验收 |
+
+
+### 取消费用确认增量(2026-10-03)
+
+| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
+|---|---|---|---|---|
+| 出库列表 | 取消确认 | 沿现有居中原因确认框;本轮首版费用规则 | 提示先解除车次、费用申请与财务结果分别处理,不再提示库存已自动回滚 | 交互检查通过,真实页面待验收 |
+| 派送详情 / 货柜费用面板 | 费用只读及作废 | 沿现有详情费用表与原因弹框;财务确认状态为本轮服务端契约补充 | 展示待财务确认/待财务处理/已作废,待决时关闭重复作废和调整 | 前端检查通过,1920×1080待验收 |
+
+
+### 原运费覆盖增量(2026-10-03)
+
+| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
+|---|---|---|---|---|
+| 排车列表 | 转派送费用确认 | 用户已确认逐票原费用覆盖;复用新版原型12374行1120px三列居中弹框 | DeliveryFeeCoveragePanel逐票显示原费和服务勾选,新增费用仅针对未覆盖部分;不新增抽屉或路由 | 交互测试通过,1920×1080真实页面待验收 |
+| 派送详情 | 费用只读 | 沿现有费用页签 | 展示确认时原费版本与服务,原金额不计作本次新增 | 本地展示测试通过,真实页面待验收 |
+
+### 退回货物重新预排(2026-10-03)
+
+| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
+|---|---|---|---|---|
+| 智能排车 | 退回来源选择、恢复草稿、预排 | 当前车辆异常退回与重派目标;沿`SmartDispatchDialog.vue`既有居中排车弹框 | 展示退回实收凭据号,按真实退回来源选用;草稿来源未全部恢复时保留选择、提示重试并阻止漏提交 | 组件交互覆盖恢复失败及重试后混合来源提交;1920×1080真实页面待验收 |
+| 异常订单 | 详情内开始处理、上传证据、分批实收、取消/完成 | 本轮完整异常退回流程接线授权;保持 `ExceptionOrdersView.vue` 既有760px居中详情弹框 | `ExceptionProcessingPanel.vue`读取权威详情、allowedActions/version;真实托盘库位选择,未知写结果保留原键与载荷,不新增撤拣或多车拆分 | 两批实收、未知结果重试、无权限、版本冲突及切单迟到响应组件交互覆盖;待人工1920×1080/100%截图确认 |
diff --git a/src/modules/overseas-warehouse/api/customerVisits.test.ts b/src/modules/overseas-warehouse/api/customerVisits.test.ts
index dd45827e..043d898a 100644
--- a/src/modules/overseas-warehouse/api/customerVisits.test.ts
+++ b/src/modules/overseas-warehouse/api/customerVisits.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { overseasHttp } from '@/shared/api/overseasHttp'
+import { http } from '@/shared/api/http'
import {
getCustomerVisit,
listCustomerVisits,
@@ -8,45 +8,45 @@ import {
uploadCustomerVisitAttachment,
} from './customerVisits'
-vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
+vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('customer visit API', () => {
beforeEach(() => {
- vi.mocked(overseasHttp.get).mockReset()
- vi.mocked(overseasHttp.post).mockReset()
- vi.mocked(overseasHttp.put).mockReset()
+ vi.mocked(http.get).mockReset()
+ vi.mocked(http.post).mockReset()
+ vi.mocked(http.put).mockReset()
})
it('loads the customer visit page and detail in customer scope', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], current: 1, size: 10, total: 0, pages: 0, visitable: 3, pending: 2, completed: 1 } })
+ vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], current: 1, size: 10, total: 0, pages: 0, visitable: 3, pending: 2, completed: 1 } })
await listCustomerVisits({ page: 1, size: 10, keyword: 'DEL' })
- expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/customer-visits', { params: { page: 1, size: 10, keyword: 'DEL' } })
+ expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/customer-visits', { params: { page: 1, size: 10, keyword: 'DEL' } })
- vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } })
+ vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } })
await getCustomerVisit('9', '100')
- expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/customer-visits/9', { params: { customerId: '100' } })
+ expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/customer-visits/9', { params: { customerId: '100' } })
})
it('updates a delivery to an operator-managed nonterminal status', async () => {
- vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERING' } })
+ vi.mocked(http.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERING' } })
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' })
- expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' }, { params: { customerId: '100' } })
+ expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' }, { params: { customerId: '100' } })
})
it('uploads and saves repeatable visit material', async () => {
const file = new File(['data'], 'visit.pdf', { type: 'application/pdf' })
- vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { fileId: '8', fileName: 'visit.pdf', sizeBytes: 4 } })
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: { fileId: '8', fileName: 'visit.pdf', sizeBytes: 4 } })
await uploadCustomerVisitAttachment('9', '100', file)
- const body = vi.mocked(overseasHttp.post).mock.calls[0]?.[1] as FormData
+ const body = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
expect(body.get('file')).toBe(file)
- expect(overseasHttp.post).toHaveBeenCalledWith(
+ expect(http.post).toHaveBeenCalledWith(
'/platform/v1/last-leg/customer-visits/9/attachments',
body,
{ params: { customerId: '100' }, headers: { 'Content-Type': 'multipart/form-data' } },
)
- vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } })
+ vi.mocked(http.put).mockResolvedValue({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } })
await saveCustomerVisit('9', '100', { selectedInboundOrderId: '7', visitResult: 'NORMAL', attachments: [] })
- expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/customer-visits/9', expect.objectContaining({ selectedInboundOrderId: '7' }), { params: { customerId: '100' } })
+ expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/customer-visits/9', expect.objectContaining({ selectedInboundOrderId: '7' }), { params: { customerId: '100' } })
})
})
diff --git a/src/modules/overseas-warehouse/api/customerVisits.ts b/src/modules/overseas-warehouse/api/customerVisits.ts
index df84a332..28de8b50 100644
--- a/src/modules/overseas-warehouse/api/customerVisits.ts
+++ b/src/modules/overseas-warehouse/api/customerVisits.ts
@@ -1,5 +1,5 @@
-import { overseasHttp } from '@/shared/api/overseasHttp'
-import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
export interface CustomerVisitAttachment {
fileId: string
@@ -63,14 +63,14 @@ export async function listCustomerVisits(params: {
completedFrom?: string
completedTo?: string
}) {
- const response = await overseasHttp.get, ApiEnvelope>(
+ const response = await http.get, ApiEnvelope>(
'/platform/v1/last-leg/customer-visits', { params },
)
return unwrapApiResponse(response)
}
export async function getCustomerVisit(visitId: string, customerId: string) {
- const response = await overseasHttp.get, ApiEnvelope>(
+ const response = await http.get, ApiEnvelope>(
`/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}`, { params: { customerId } },
)
return unwrapApiResponse(response)
@@ -83,7 +83,7 @@ export async function saveCustomerVisit(visitId: string, customerId: string, pay
remark?: string
attachments: CustomerVisitAttachment[]
}) {
- const response = await overseasHttp.put, ApiEnvelope>(
+ const response = await http.put, ApiEnvelope>(
`/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}`, payload, { params: { customerId } },
)
return unwrapApiResponse(response)
@@ -92,7 +92,7 @@ export async function saveCustomerVisit(visitId: string, customerId: string, pay
export async function uploadCustomerVisitAttachment(visitId: string, customerId: string, file: File) {
const body = new FormData()
body.append('file', file)
- const response = await overseasHttp.post, ApiEnvelope>(
+ const response = await http.post, ApiEnvelope>(
`/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}/attachments`, body,
{ params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' } },
)
@@ -100,7 +100,7 @@ export async function uploadCustomerVisitAttachment(visitId: string, customerId:
}
export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; expectedVersion: number; remark?: string }) {
- const response = await overseasHttp.put, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>(
+ const response = await http.put, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, payload,
{ params: { customerId } },
)
diff --git a/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts b/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts
index bc07ffb8..ecb1b423 100644
--- a/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts
+++ b/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts
@@ -1,28 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { overseasHttp } from '@/shared/api/overseasHttp'
+import { http } from '@/shared/api/http'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument } from './deliveryDocuments'
-vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
+vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('delivery document API', () => {
beforeEach(() => vi.clearAllMocks())
it('scopes reads and full three-category snapshots by customer and delivery number', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: {} })
- vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: {} })
+ vi.mocked(http.put).mockResolvedValue({ code: '200', data: {} })
await getDeliveryDocuments('DL/1', '9007199254740993')
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', { params: { customerId: '9007199254740993' } })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', { params: { customerId: '9007199254740993' } })
const body = { expectedVersion: 8, idempotencyKey: 'save-1', bolNo: null, documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) }
await saveDeliveryDocuments('DL/1', '9007199254740993', body)
- expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', body, { params: { customerId: '9007199254740993' } })
+ expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', body, { params: { customerId: '9007199254740993' } })
})
it('uploads only the file and downloads through the authenticated category endpoint', async () => {
- vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740995' } })
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740995' } })
const file = new File(['data'], 'proof.bin')
await uploadDeliveryDocument('DL/1', '100', 'RECEIPT', file)
- const [url, body, config] = vi.mocked(overseasHttp.post).mock.calls[0]!
+ const [url, body, config] = vi.mocked(http.post).mock.calls[0]!
expect(url).toBe('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files')
expect(Array.from((body as FormData).keys())).toEqual(['file'])
expect((body as FormData).get('file')).toBe(file)
expect(config).toEqual({ params: { customerId: '100' } })
await downloadDeliveryDocument('DL/1', '100', 'RECEIPT', '9007199254740995')
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files/9007199254740995/download', { params: { customerId: '100' }, responseType: 'blob' })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files/9007199254740995/download', { params: { customerId: '100' }, responseType: 'blob' })
})
})
diff --git a/src/modules/overseas-warehouse/api/deliveryDocuments.ts b/src/modules/overseas-warehouse/api/deliveryDocuments.ts
index 13eaf4e0..a13a21ad 100644
--- a/src/modules/overseas-warehouse/api/deliveryDocuments.ts
+++ b/src/modules/overseas-warehouse/api/deliveryDocuments.ts
@@ -1,5 +1,5 @@
-import { overseasHttp } from '@/shared/api/overseasHttp'
-import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
export const deliveryDocumentCategories = [
{ category: 'LOADING_PHOTO', label: '装车照片' },
@@ -18,16 +18,16 @@ export interface DeliveryDocuments extends DeliveryDocumentSummary {
export interface SaveDeliveryDocuments { expectedVersion: number; idempotencyKey: string; bolNo: string | null; documents: Array<{ category: DeliveryDocumentCategory; fileIds: string[] }> }
const path = (deliveryNo: string) => `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`
export async function getDeliveryDocuments(deliveryNo: string, customerId: string) {
- return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(path(deliveryNo), { params: { customerId } }))
+ return unwrapApiResponse(await http.get, ApiEnvelope>(path(deliveryNo), { params: { customerId } }))
}
export async function saveDeliveryDocuments(deliveryNo: string, customerId: string, body: SaveDeliveryDocuments) {
- return unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(path(deliveryNo), body, { params: { customerId } }))
+ return unwrapApiResponse(await http.put, ApiEnvelope>(path(deliveryNo), body, { params: { customerId } }))
}
export async function uploadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, file: File) {
const body = new FormData()
body.append('file', file)
- return unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${path(deliveryNo)}/${category}/files`, body, { params: { customerId } }))
+ return unwrapApiResponse(await http.post, ApiEnvelope>(`${path(deliveryNo)}/${category}/files`, body, { params: { customerId } }))
}
export async function downloadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, fileId: string) {
- return overseasHttp.get(`${path(deliveryNo)}/${category}/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
+ return http.get(`${path(deliveryNo)}/${category}/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
}
diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.test.ts b/src/modules/overseas-warehouse/api/deliveryOrders.test.ts
index 2aa59f70..50e62a67 100644
--- a/src/modules/overseas-warehouse/api/deliveryOrders.test.ts
+++ b/src/modules/overseas-warehouse/api/deliveryOrders.test.ts
@@ -1,48 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { overseasHttp } from '@/shared/api/overseasHttp'
+import { http } from '@/shared/api/http'
import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from './deliveryOrders'
-vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
+vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } }))
describe('delivery order API', () => {
beforeEach(() => {
- vi.mocked(overseasHttp.get).mockReset()
- vi.mocked(overseasHttp.post).mockReset()
+ vi.mocked(http.get).mockReset()
+ vi.mocked(http.post).mockReset()
})
it('passes paging and expanded filters and normalizes page numbers', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: '2', size: '20', total: '41', pages: '3' } })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: '2', size: '20', total: '41', pages: '3' } })
const result = await listDeliveryOrders({ page: 2, size: 20, documentNo: 'DEL-1', vehiclePlate: 'CA-1' })
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders', { params: { page: 2, size: 20, documentNo: 'DEL-1', vehiclePlate: 'CA-1' } })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders', { params: { page: 2, size: 20, documentNo: 'DEL-1', vehiclePlate: 'CA-1' } })
expect(result).toMatchObject({ page: 2, size: 20, total: 41, pages: 3 })
})
it('loads an encoded detail in its explicit customer scope', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1', version: '0' }, skus: [], fees: [], operations: [] } })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1', version: '0' }, skus: [], fees: [], operations: [] } })
await getDeliveryOrder('DEL/1', '1001')
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } })
+ })
+
+ it.each([null, 0, 3])('preserves actual nullable signed quantity %s in list and detail', async signedPackages => {
+ const order = { deliveryNo: 'DEL-1', version: '0', packageCount: 9, signedPackages }
+ vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [order], page: 1, size: 20, total: 1, pages: 1 } })
+ const page = await listDeliveryOrders({ page: 1, size: 20 })
+ expect(page.records[0]?.signedPackages).toBe(signedPackages)
+ vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { order, skus: [], fees: [], operations: [] } })
+ expect((await getDeliveryOrder('DEL-1', '1001')).order.signedPackages).toBe(signedPackages)
})
it.each(['0', '7', '9007199254740991'])('reads Java Long version %s for status editing', async version => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { version, id: '2097515131537313794' } } })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version, id: '2097515131537313794' } } })
const detail = await getDeliveryOrder('DEL/1', '1001')
expect(detail.order.version).toBe(Number(version))
expect(detail.order.id).toBe('2097515131537313794')
})
it.each([null, undefined, '', ' ', '-1', '1.5', '9007199254740992', -1])('rejects invalid version %s', async version => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { version } } })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version } } })
await expect(getDeliveryOrder('DEL/1', '1001')).rejects.toThrow('派送单缺少有效版本')
})
it('loads return options and marks a return in the explicit customer scope', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] })
await listDeliveryReturnInboundOptions('DEL/1', '1001')
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
- vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
await markDeliveryReturn('DEL/1', '1001', payload)
- expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
+ expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
})
})
diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.ts b/src/modules/overseas-warehouse/api/deliveryOrders.ts
index 339f62ae..86e51b1a 100644
--- a/src/modules/overseas-warehouse/api/deliveryOrders.ts
+++ b/src/modules/overseas-warehouse/api/deliveryOrders.ts
@@ -1,5 +1,5 @@
-import { overseasHttp } from '@/shared/api/overseasHttp'
-import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
import type { DeliveryDocumentSummary } from './deliveryDocuments'
@@ -54,6 +54,7 @@ export interface DeliveryOrderRow {
sellerName?: string | null
palletCount: number
packageCount: number
+ signedPackages: number | null
totalWeightKg?: number | null
totalVolumeCbm?: number | null
plannedDeliveryAt?: string | null
@@ -64,10 +65,10 @@ export interface DeliveryOrderRow {
createdAt: string
}
-export interface DeliverySkuRow { inboundCode?: string | null; customerName?: string | null; destinationWarehouseCode?: string | null; receivingAddress?: string | null; shipmentNo?: string | null; fbaTrackingNo?: string | null; skuCode?: string | null; productName?: string | null; packageCount: number; weightKg?: number | null; volumeCbm?: number | null }
-export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null }
+export interface DeliverySkuRow { inboundCode?: string | null; customerName?: string | null; destinationWarehouseCode?: string | null; receivingAddress?: string | null; shipmentNo?: string | null; fbaTrackingNo?: string | null; skuCode?: string | null; productName?: string | null; packageCount: number; productQuantity?: number | null; weightKg?: number | null; volumeCbm?: number | null }
+export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null; reviewStatus?: string | null; sourceStatus?: string | null; voidStatus?: string | null }
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
-export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] }
+export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[]; feeCoverage?: import('../feeCoverageTypes').FeeCoverageEvidence | null }
export interface DeliveryReturnSource {
outboundPalletItemId: string
@@ -94,14 +95,14 @@ export interface MarkDeliveryReturnPayload {
export async function listDeliveryOrders(query: DeliveryOrderQuery) {
const { customerId, ...params } = query
- const response = await overseasHttp.get>, ApiEnvelope>>(
+ const response = await http.get>, ApiEnvelope>>(
'/platform/v1/last-leg/delivery-orders', { params: { ...params, ...(customerId ? { customerId } : {}) } },
)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getDeliveryOrder(deliveryNo: string, customerId: string) {
- const response = await overseasHttp.get, ApiEnvelope>(
+ const response = await http.get, ApiEnvelope>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, { params: { customerId, page: 1, size: 200 } },
)
const detail = unwrapApiResponse(response)
@@ -116,14 +117,14 @@ export async function getDeliveryOrder(deliveryNo: string, customerId: string) {
}
export async function listDeliveryReturnInboundOptions(deliveryNo: string, customerId: string) {
- const response = await overseasHttp.get, ApiEnvelope>(
+ const response = await http.get, ApiEnvelope>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: MarkDeliveryReturnPayload) {
- const response = await overseasHttp.post, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>(
+ const response = await http.post, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, payload, { params: { customerId } },
)
return unwrapApiResponse(response)
diff --git a/src/modules/overseas-warehouse/api/dispatches.test.ts b/src/modules/overseas-warehouse/api/dispatches.test.ts
index 4468258d..1affac69 100644
--- a/src/modules/overseas-warehouse/api/dispatches.test.ts
+++ b/src/modules/overseas-warehouse/api/dispatches.test.ts
@@ -1,19 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { overseasHttp } from '@/shared/api/overseasHttp'
-import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
+import { http } from '@/shared/api/http'
+import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
-vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
+vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
describe('dispatch overseas API', () => {
+ it('submits all explicit draft groups once and preserves container and source identities', async () => {
+ const payload = { draftToken: 'original-draft', groups: [
+ { draftContainerId: '190000000000000099', planPalletIds: ['11', '13'], returnReceiptIds: [] },
+ { draftContainerId: '190000000000000100', planPalletIds: ['12'], returnReceiptIds: ['22'] },
+ ] }
+ const result = payload.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: { id: String(index), dispatchNo: `DS-${index}` } }))
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: result })
+ expect(await preArrangeDraftDispatch(payload)).toEqual(result)
+ expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload)
+ })
+ it('propagates a batch rejection without falling back to a flat or repeated command', async () => {
+ vi.mocked(http.post).mockResolvedValue({ code: 'A0409', message: '草稿来源已变化', data: null })
+ await expect(preArrangeDraftDispatch({ draftToken: 'original-draft', groups: [
+ { draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: [] },
+ ] })).rejects.toThrow('草稿来源已变化')
+ expect(http.post).toHaveBeenCalledTimes(1)
+ })
+ it('locks and submits actual return receipts without pretending they are plan pallets', async () => {
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: null })
+ await lockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return', 'container-1')
+ expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', {
+ sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return', draftContainerId: 'container-1',
+ })
+ await unlockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return')
+ expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/unlock', {
+ sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return',
+ })
+ await preArrangeDispatch({ planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return' })
+ expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', {
+ planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return',
+ })
+ })
+ it('reads external freight from the scoped dispatch without a supplied customer', async () => {
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] } })
+ expect(await getDispatchExternalFreight('DP/1')).toEqual({ snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/external-freight')
+ })
+ it('loads per-shipment coverage without exposing a caller-supplied customer or losing long IDs', async () => {
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { dispatchNo: 'DP/1', sourceComplete: true, blockReason: null, cargoHash: 'hash', cargo: [], feeSources: [{ financeFeeId: '190000000000000099', sourceFeeId: '190000000000000100', customerId: '101', sourceInboundId: '11', sourceVersion: '2', amount: '100.25' }] } })
+ const result = await getDispatchFeeCoverage('DP/1')
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/fee-coverage')
+ expect(result.feeSources[0]).toMatchObject({ financeFeeId: '190000000000000099', sourceVersion: 2, amount: 100.25 })
+ })
beforeEach(() => {
- vi.mocked(overseasHttp.get).mockReset()
- vi.mocked(overseasHttp.post).mockReset()
- vi.mocked(overseasHttp.put).mockReset()
- vi.mocked(overseasHttp.delete).mockReset()
+ vi.mocked(http.get).mockReset()
+ vi.mocked(http.post).mockReset()
+ vi.mocked(http.put).mockReset()
+ vi.mocked(http.delete).mockReset()
})
it('uses the platform dispatch page without customer scope', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({
+ vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 },
})
@@ -22,7 +65,7 @@ describe('dispatch overseas API', () => {
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
})
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', {
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', {
params: {
page: 2, size: 50, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
@@ -31,78 +74,78 @@ describe('dispatch overseas API', () => {
})
it('loads a platform dispatch detail by encoded dispatch number', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
await getDispatch('PC/1')
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('requests the generated BOL as a PDF blob', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue(new Blob(['%PDF-test']))
+ vi.mocked(http.get).mockResolvedValue(new Blob(['%PDF-test']))
await getDispatchBolPdf('PC/1')
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' })
})
it('loads candidates and creates a pre-arrange dispatch in an explicit customer scope', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
- vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
+ vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
await listDispatchCandidates({ planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' })
await preArrangeDispatch({ planPalletIds: ['190000000000000099'] })
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
- expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'] })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
+ expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'] })
})
it('loads platform candidates without selected inbound plans', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
await listDispatchCandidates({ sourceStage: 'RECEIVED' })
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', {
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', {
params: { sourceStage: 'RECEIVED' },
})
})
it('locks, unlocks, and releases intelligent dispatch drafts through dedicated endpoints', async () => {
- vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: null })
+ vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
await lockDispatchDraftPallet('99', 'draft-1')
await unlockDispatchDraftPallet('99', 'draft-1')
await releaseDispatchDraft('draft-1')
- expect(overseasHttp.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId: '99', draftToken: 'draft-1' })
- expect(overseasHttp.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
- expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
+ expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId: '99', draftToken: 'draft-1' })
+ expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
+ expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
})
it('edits and cancels only by dispatchNo without customer scope or status mutation', async () => {
- vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
- vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
+ vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
+ vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await updateDispatch('PC/1', { vehiclePlate: 'CA-100' })
await cancelDispatch('PC/1')
- expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100' })
- expect(overseasHttp.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() }))
- expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
+ expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100' })
+ expect(http.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() }))
+ expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('transfers to delivery only by dispatchNo without customerId', async () => {
- vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } })
+ vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } })
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费用', direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: 10 }] }
await transferDispatchToDelivery('PC/1', payload)
- expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
+ expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
})
it('loads drafts and preserves long container ids in create and delete paths', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
- vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 30, palletIds: [] } })
- vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
+ vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
+ vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 30, palletIds: [] } })
+ vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await listDispatchDrafts()
await createDispatchDraftContainer('draft/1', 30)
await deleteDispatchDraftContainer('draft/1', '190000000000000099')
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts')
- expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 30 })
- expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099')
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts')
+ expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 30 })
+ expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099')
})
})
diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts
index 26b7410e..9c272b87 100644
--- a/src/modules/overseas-warehouse/api/dispatches.ts
+++ b/src/modules/overseas-warehouse/api/dispatches.ts
@@ -1,5 +1,5 @@
-import { overseasHttp } from '@/shared/api/overseasHttp'
-import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface DispatchQuery {
@@ -12,6 +12,9 @@ export interface DispatchQuery {
}
export interface DispatchPalletCandidateDto {
+ sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
+ returnReceiptId?: string | null
+ returnReceiptNo?: string | null
planId?: string | null
planPalletId?: string | null
warehousePalletId?: string | null
@@ -60,6 +63,7 @@ export interface DispatchDraftDto { draftToken: string; containers?: DispatchDra
export interface PreArrangeDispatchPayload {
planPalletIds: string[]
+ returnReceiptIds?: string[]
fbaAppointmentNo?: string
fbaAppointmentStatus?: string
vehicleAppointmentStatus?: string
@@ -67,6 +71,20 @@ export interface PreArrangeDispatchPayload {
relabelNo?: string
}
+export interface PreArrangeDispatchDraftGroup {
+ draftContainerId: string
+ planPalletIds: string[]
+ returnReceiptIds: string[]
+}
+export interface PreArrangeDispatchDraftPayload {
+ draftToken: string
+ groups: PreArrangeDispatchDraftGroup[]
+}
+export interface PreArrangeDispatchDraftResult {
+ draftContainerId: string
+ dispatch: DispatchSummaryDto
+}
+
export interface DispatchSummaryDto {
id: string
dispatchNo: string
@@ -140,7 +158,7 @@ export interface DispatchItemDto {
}
export async function listDispatches(query: DispatchQuery) {
- const response = await overseasHttp.get>, ApiEnvelope>>(
+ const response = await http.get>, ApiEnvelope>>(
'/platform/v1/last-leg/dispatches',
{ params: query },
)
@@ -148,42 +166,42 @@ export async function listDispatches(query: DispatchQuery) {
}
export async function getDispatch(dispatchNo: string) {
- const response = await overseasHttp.get, ApiEnvelope>(
+ const response = await http.get, ApiEnvelope>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
)
return unwrapApiResponse(response)
}
export async function getDispatchBolPdf(dispatchNo: string) {
- return overseasHttp.get(
+ return http.get(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/prints/bol`,
{ responseType: 'blob' },
)
}
export async function updateDispatch(dispatchNo: string, payload: UpdateDispatchPayload) {
- const response = await overseasHttp.put, ApiEnvelope>(
+ const response = await http.put, ApiEnvelope>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, payload,
)
return unwrapApiResponse(response)
}
export async function cancelDispatch(dispatchNo: string) {
- const response = await overseasHttp.delete, ApiEnvelope>(
+ const response = await http.delete, ApiEnvelope>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
)
return unwrapApiResponse(response)
}
export async function removeDispatchItem(dispatchNo: string, itemId: string) {
- const response = await overseasHttp.delete, ApiEnvelope>(
+ const response = await http.delete, ApiEnvelope>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(itemId)}`,
)
return unwrapApiResponse(response)
}
export async function listDispatchCandidates(query: DispatchCandidateQuery) {
- const response = await overseasHttp.get, ApiEnvelope>(
+ const response = await http.get, ApiEnvelope>(
'/platform/v1/last-leg/dispatches/candidates',
{ params: query },
)
@@ -191,42 +209,54 @@ export async function listDispatchCandidates(query: DispatchCandidateQuery) {
}
export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) {
- const response = await overseasHttp.post, ApiEnvelope>(
+ const response = await http.post