Release internal fleet capacity pool to gray #14
@@ -18,7 +18,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '出车记录'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
@@ -26,7 +26,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(11)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(46)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(47)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DashboardView from './DashboardView.vue'
|
||||
import { getTodayNewOrderCount } from '../api/dashboard'
|
||||
import { getTodayNewOrderCount, getActiveCustomerCount, getDashboardOperationalMetrics } from '../api/dashboard'
|
||||
|
||||
const access = vi.hoisted(() => ({ permissions: new Set<string>() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) }))
|
||||
|
||||
vi.mock('../api/dashboard', () => ({
|
||||
getTodayNewOrderCount: vi.fn().mockResolvedValue(12),
|
||||
@@ -18,7 +21,28 @@ vi.mock('../api/dashboard', () => ({
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('DashboardView', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
access.permissions = new Set(['platform:order:list', 'platform:customer:list', 'platform:firstleg:container:list', 'platform:firstleg:customs:list'])
|
||||
})
|
||||
|
||||
it('makes no metric requests for a minimal pool sales account and gives an authorized-menu hint', async () => {
|
||||
access.permissions = new Set(['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:capacity-offer:inquiry-edit', 'platform:fleet:capacity-offer:inquiry-cancel'])
|
||||
const wrapper = mount(DashboardView); await flushPromises()
|
||||
expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getActiveCustomerCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('请从左侧菜单进入已授权业务')
|
||||
expect(wrapper.text()).not.toContain('指标加载失败'); expect(wrapper.text()).not.toContain('刷新数据')
|
||||
})
|
||||
|
||||
it('requests only granted metric sources and requires both container and customs permissions', async () => {
|
||||
access.permissions = new Set(['platform:customer:list', 'platform:firstleg:container:list'])
|
||||
const wrapper = mount(DashboardView); await flushPromises()
|
||||
expect(getActiveCustomerCount).toHaveBeenCalledOnce(); expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.dashboard-metric p').map(node => node.text())).toEqual(['活跃客户'])
|
||||
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click'); await flushPromises()
|
||||
expect(getActiveCustomerCount).toHaveBeenCalledTimes(2); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders only the four live metrics', async () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RefreshCw,
|
||||
} from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import {
|
||||
getActiveCustomerCount,
|
||||
getDashboardOperationalMetrics,
|
||||
@@ -22,6 +23,11 @@ const todayNewOrderCount = ref<number | null>(null)
|
||||
const activeCustomerCount = ref<number | null>(null)
|
||||
const operationalMetrics = ref<DashboardOperationalMetrics | null>(null)
|
||||
const metricsUpdatedAt = ref<string | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
const canReadOrders = computed(() => access.can('platform:order:list'))
|
||||
const canReadCustomers = computed(() => access.can('platform:customer:list'))
|
||||
const canReadOperations = computed(() => access.can('platform:firstleg:container:list') && access.can('platform:firstleg:customs:list'))
|
||||
const hasMetrics = computed(() => canReadOrders.value || canReadCustomers.value || canReadOperations.value)
|
||||
|
||||
function displayNumber(value: number | null | undefined, maximumFractionDigits = 0) {
|
||||
if (metricsLoading.value) return '…'
|
||||
@@ -30,17 +36,18 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits =
|
||||
}
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' },
|
||||
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue', visible: canReadOrders.value },
|
||||
{
|
||||
label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU',
|
||||
note: operationalMetrics.value
|
||||
? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}`
|
||||
: '已开船且尚未到仓',
|
||||
tone: 'green',
|
||||
visible: canReadOperations.value,
|
||||
},
|
||||
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple' },
|
||||
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' },
|
||||
])
|
||||
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple', visible: canReadCustomers.value },
|
||||
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose', visible: canReadOperations.value },
|
||||
].filter(metric => metric.visible))
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen()
|
||||
@@ -55,17 +62,17 @@ async function loadMetrics() {
|
||||
metricsLoading.value = true
|
||||
metricsError.value = false
|
||||
const [orders, customers, operations] = await Promise.allSettled([
|
||||
getTodayNewOrderCount(),
|
||||
getActiveCustomerCount(),
|
||||
getDashboardOperationalMetrics(),
|
||||
canReadOrders.value ? getTodayNewOrderCount() : Promise.resolve(null),
|
||||
canReadCustomers.value ? getActiveCustomerCount() : Promise.resolve(null),
|
||||
canReadOperations.value ? getDashboardOperationalMetrics() : Promise.resolve(null),
|
||||
])
|
||||
todayNewOrderCount.value = orders.status === 'fulfilled' ? orders.value : null
|
||||
activeCustomerCount.value = customers.status === 'fulfilled' ? customers.value : null
|
||||
operationalMetrics.value = operations.status === 'fulfilled' ? operations.value : null
|
||||
metricsError.value = [orders, customers, operations].some(result => result.status === 'rejected')
|
||||
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value.asOf
|
||||
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value?.asOf
|
||||
? operations.value.asOf
|
||||
: dayjs().format('YYYY-MM-DDTHH:mm:ss')
|
||||
: hasMetrics.value ? dayjs().format('YYYY-MM-DDTHH:mm:ss') : null
|
||||
metricsLoading.value = false
|
||||
}
|
||||
|
||||
@@ -80,10 +87,10 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
<section ref="dashboardRef" class="dashboard-view">
|
||||
<AdminPage as="div" spacing="normal">
|
||||
<section class="dashboard-context" aria-label="数据看板说明">
|
||||
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
||||
<p>{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
<span v-if="hasMetrics">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
<RefreshCw :size="16" aria-hidden="true" />
|
||||
刷新数据
|
||||
</button>
|
||||
@@ -95,7 +102,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-metrics" aria-label="核心业务指标">
|
||||
<div v-if="hasMetrics" class="dashboard-metrics" aria-label="核心业务指标">
|
||||
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
|
||||
<span class="dashboard-metric-mark" aria-hidden="true" />
|
||||
<p>{{ metric.label }}</p>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# 海外业务原型核对矩阵
|
||||
|
||||
## 池来源本土订单承运安排
|
||||
|
||||
依据:[内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7节及本轮用户授权。
|
||||
|
||||
| 页面 | 操作 | 确认承载与实现 |
|
||||
| --- | --- | --- |
|
||||
| 本土卡车 / 本土散板列表 | 池来源承运安排 | 1080px居中只读摘要,使用列表已有fleetArrangement,打开不增加请求;不显示旧编辑动作 |
|
||||
| 本土卡车 / 本土散板旧编辑深链 | 池来源只读 | 保持独立路由,读取原订单详情后仅展示真实安排与只读原因,不提交旧手工流程 |
|
||||
| 承运安排 | 关联单据 | 有目标权限才开放车次/排车跳转,否则仅显示业务号;未知签收数量显示“—” |
|
||||
| Client本土订单 | 列表摘要 / 原独立详情 | 同一客户订单响应展示承运安排,客户不访问运力池,不提供状态写入 |
|
||||
|
||||
独立来源订单保持原承载。本轮视觉按1920×1080内置浏览器验收,自动检查与实际页面证据分别报告。
|
||||
|
||||
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/07-overseas-business.html`;按钮函数来源:同原型目录 `prototype-src/app.js`。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 |
|
||||
|
||||
@@ -107,4 +107,12 @@ describe('platform overseas business API adapter', () => {
|
||||
expect(overseas.post).not.toHaveBeenCalled()
|
||||
expect(overseas.put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves unknown pool pallet service and container type in list and detail responses', async () => {
|
||||
const summary = { ...looseboardSummary, palletRequired: null, containerType: null }
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] }))
|
||||
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, containerType: null })
|
||||
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, containerType: null })
|
||||
expect(overseas.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { mount, enableAutoUnmount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { compileStyle, parse } from 'vue/compiler-sfc'
|
||||
import FleetArrangementSummary from './FleetArrangementSummary.vue'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
|
||||
const state = vi.hoisted(() => ({ writableLinks: true, push: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.writableLinks }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }) }))
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => { state.writableLinks = true; state.push.mockClear() })
|
||||
const arrangement: FleetArrangement = {
|
||||
sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'LOCAL_LOOSEBOARD', businessOrderNo: 'LB-ONT-20261007-309123', bookingNo: 'FB-2107769452587171841', tripNo: 'TR-76DB64B4457C4206',
|
||||
plannedDepartureAt: '2026-10-07T13:46:07Z', originWarehouseName: '洛杉矶仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: '交货说明完整保留', fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null,
|
||||
agreedFreightAmount: 30, currency: 'CNY', incomingNo: 'WT-ONT-20261007-12345678901234567890', dispatchNo: 'DP-ONT-20261007-12345678901234567890', deliveryNo: 'DL-ONT-20261007-12345678901234567890',
|
||||
signedPackages: 0, unsignedPackages: null, returnedPackages: 0, exceptionSummary: null, readOnlyReason: '此订单由拼车承运实际履约推进',
|
||||
}
|
||||
function render() { return mount(FleetArrangementSummary, { props: { arrangement }, global: { plugins: [ElementPlus] } }) }
|
||||
it('retains all long business numbers, labels and authorized navigation', async () => {
|
||||
const wrapper = render()
|
||||
const rows = wrapper.findAll('.fleet-arrangement-fields > div')
|
||||
expect(rows).toHaveLength(14)
|
||||
for (const [label, value] of [['订单号', arrangement.businessOrderNo], ['外接预订号', arrangement.bookingNo], ['车次号', arrangement.tripNo], ['来货单号', arrangement.incomingNo], ['排车单号', arrangement.dispatchNo], ['派送单号', arrangement.deliveryNo]]) {
|
||||
expect(rows.find(row => row.get('dt').text() === label)!.get('dd').text()).toBe(value)
|
||||
}
|
||||
expect(rows.find(row => row.get('dt').text() === '已签 / 未签 / 退回箱数')!.get('dd').text()).toBe('0 / — / 0')
|
||||
const links = wrapper.findAll('button.fleet-arrangement-reference')
|
||||
expect(links.map(link => link.text())).toEqual([arrangement.tripNo, arrangement.dispatchNo])
|
||||
await links[0]!.trigger('click'); await links[1]!.trigger('click')
|
||||
expect(state.push).toHaveBeenNthCalledWith(1, { name: 'overseas-master-data-trip-detail', params: { tripNo: arrangement.tripNo } })
|
||||
expect(state.push).toHaveBeenNthCalledWith(2, { name: 'overseas-dispatch-detail', params: { dispatchNo: arrangement.dispatchNo } })
|
||||
})
|
||||
it('keeps full reference text without links when target permissions are absent', () => {
|
||||
state.writableLinks = false
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain(arrangement.tripNo); expect(wrapper.text()).toContain(arrangement.dispatchNo)
|
||||
})
|
||||
it('uses a scoped two-column layout and wrap-capable reference controls rather than truncation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-business/components/FleetArrangementSummary.vue'), 'utf8')
|
||||
const style = parse(source).descriptor.styles[0]!
|
||||
expect(style.scoped).toBe(true)
|
||||
const compiled = compileStyle({ source: style.content, filename: 'FleetArrangementSummary.vue', id: 'data-v-arrangement', scoped: true })
|
||||
expect(compiled.errors).toEqual([])
|
||||
const rules = new Map<string, Record<string, string>>()
|
||||
compiled.rawResult!.root.walkRules(rule => {
|
||||
const declarations: Record<string, string> = {}
|
||||
rule.walkDecls(declaration => { declarations[declaration.prop] = declaration.value })
|
||||
rules.set(rule.selector, declarations)
|
||||
})
|
||||
const grid = [...rules].find(([selector]) => selector.includes('.fleet-arrangement-fields.density-fields'))!
|
||||
expect(grid[0]).toContain('[data-v-arrangement]')
|
||||
expect(grid[1]).toMatchObject({ 'grid-template-columns': 'repeat(2, minmax(0, 1fr))', '--admin-field-label-width': '160px' })
|
||||
const button = [...rules].find(([selector]) => selector.endsWith('.fleet-arrangement-reference[data-v-arrangement]'))!
|
||||
expect(button[1]).toMatchObject({ 'max-width': '100%', 'min-width': '0', height: 'auto', 'white-space': 'normal', 'text-align': 'left' })
|
||||
const text = [...rules].find(([selector]) => selector.endsWith(' span'))!
|
||||
expect(text[1]).toMatchObject({ 'white-space': 'normal', 'overflow-wrap': 'anywhere', 'min-width': '0' })
|
||||
expect(style.content).not.toMatch(/text-overflow:\s*ellipsis|overflow:\s*hidden|white-space:\s*nowrap/)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
|
||||
defineProps<{ arrangement: FleetArrangement }>()
|
||||
const access = useAccessPreviewStore(), router = useRouter()
|
||||
const time = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="承运安排">
|
||||
<p>{{ arrangement.readOnlyReason }}</p>
|
||||
<dl class="density-fields fleet-arrangement-fields">
|
||||
<div><dt>订单号</dt><dd>{{ arrangement.businessOrderNo }}</dd></div>
|
||||
<div><dt>外接预订号</dt><dd>{{ arrangement.bookingNo }}</dd></div>
|
||||
<div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" class="fleet-arrangement-reference" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: arrangement.tripNo } })">{{ arrangement.tripNo }}</el-button><span v-else>{{ arrangement.tripNo }}</span></dd></div>
|
||||
<div><dt>计划发车</dt><dd>{{ time(arrangement.plannedDepartureAt) }}</dd></div>
|
||||
<div><dt>起运仓</dt><dd>{{ arrangement.originWarehouseName || '—' }}</dd></div>
|
||||
<div><dt>目的地</dt><dd>{{ arrangement.destinationLabel || '—' }}</dd></div>
|
||||
<div><dt>交货方式</dt><dd>{{ arrangement.deliveryMode === 'SELF_DELIVERY' ? '客户自送我仓' : '线下提货回我仓' }}</dd></div>
|
||||
<div><dt>履约阶段</dt><dd>{{ poolOrderStatusLabels[arrangement.fulfillmentStage] || '待核对履约进度' }}</dd></div>
|
||||
<div><dt>阶段时间</dt><dd>{{ time(arrangement.stageOccurredAt) }}</dd></div>
|
||||
<div><dt>约定运费(非实收)</dt><dd>{{ arrangement.agreedFreightAmount == null ? '—' : `${Number(arrangement.agreedFreightAmount).toFixed(2)} ${arrangement.currency || ''}` }}</dd></div>
|
||||
<div><dt>来货单号</dt><dd>{{ arrangement.incomingNo || '—' }}</dd></div>
|
||||
<div><dt>排车单号</dt><dd><el-button v-if="arrangement.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" class="fleet-arrangement-reference" link type="primary" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: arrangement.dispatchNo } })">{{ arrangement.dispatchNo }}</el-button><span v-else>{{ arrangement.dispatchNo || '—' }}</span></dd></div>
|
||||
<div><dt>派送单号</dt><dd>{{ arrangement.deliveryNo || '—' }}</dd></div>
|
||||
<div><dt>已签 / 未签 / 退回箱数</dt><dd>{{ arrangement.signedPackages ?? '—' }} / {{ arrangement.unsignedPackages ?? '—' }} / {{ arrangement.returnedPackages ?? '—' }}</dd></div>
|
||||
</dl>
|
||||
<p v-if="arrangement.deliveryInstructions">交货说明:{{ arrangement.deliveryInstructions }}</p>
|
||||
<p v-if="arrangement.exceptionSummary" role="status">履约说明:{{ arrangement.exceptionSummary }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fleet-arrangement-fields.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); --admin-field-label-width: 160px; }
|
||||
.fleet-arrangement-fields dt { overflow-wrap: anywhere; }
|
||||
.fleet-arrangement-fields .fleet-arrangement-reference { max-width: 100%; min-width: 0; height: auto; justify-content: flex-start; white-space: normal; text-align: left; line-height: inherit; }
|
||||
.fleet-arrangement-reference :deep(span) { display: block; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface FleetArrangement {
|
||||
sourceType: 'FLEET_CAPACITY_OFFER'
|
||||
businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
|
||||
businessOrderNo: string
|
||||
bookingNo: string
|
||||
tripNo: string
|
||||
plannedDepartureAt: string | null
|
||||
originWarehouseName: string
|
||||
destinationLabel: string
|
||||
deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE'
|
||||
deliveryInstructions: string | null
|
||||
fulfillmentStage: string
|
||||
stageOccurredAt: string | null
|
||||
agreedFreightAmount: number | null
|
||||
currency: string | null
|
||||
incomingNo: string | null
|
||||
dispatchNo: string | null
|
||||
deliveryNo: string | null
|
||||
signedPackages: number | null
|
||||
unsignedPackages: number | null
|
||||
returnedPackages: number | null
|
||||
exceptionSummary: string | null
|
||||
readOnlyReason: string
|
||||
}
|
||||
|
||||
export const poolOrderStatusLabels: Record<string, string> = {
|
||||
WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
|
||||
PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消',
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
|
||||
import {
|
||||
createOverseasPurchaseFee, uploadOverseasPurchaseFile, createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
|
||||
@@ -44,10 +45,11 @@ const purchase: OverseasPurchaseDetail = {
|
||||
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
|
||||
}
|
||||
const truck: LocalTransportOrder = { id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
|
||||
const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
|
||||
|
||||
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
|
||||
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
|
||||
@@ -65,6 +67,49 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
|
||||
|
||||
it('opens pool arrangements from the list without another request or exposing edit actions', async () => {
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
expect(wrapper.findAll('.el-table__body-wrapper button').some(b => b.text() === '编辑')).toBe(false)
|
||||
await wrapper.findAll('.el-table__body-wrapper button').find(b => b.text() === '承运安排')!.trigger('click'); await flushPromises()
|
||||
expect(document.body.textContent).toContain('池来源订单按实际运输履约推进')
|
||||
expect(document.body.textContent).toContain('FB-TEST'); expect(document.body.textContent).toContain('— / — / —')
|
||||
expect(listUsTruckOrders).toHaveBeenCalledOnce(); expect(getUsTruckOrder).not.toHaveBeenCalled()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
await dialog.findAll('.app-dialog__footer button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
it('keeps a pool order read-only even when an old edit deep link is used', async () => {
|
||||
routing.route.params = { orderId: 'TR-200' }; routing.route.query = { customerId: truck.customerId }
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue({ ...truckDetail, fleetArrangement: arrangement, status: 'WAITING_INBOUND' })
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('池来源订单按实际运输履约推进')
|
||||
expect(wrapper.find('form').exists()).toBe(false); expect(wrapper.findAll('button').some(b => b.text() === '保存修改')).toBe(false)
|
||||
expect(updateUsTruckOrder).not.toHaveBeenCalled(); expect(getUsTruckOrder).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('distinguishes unknown pool pallet service from explicit no and yes in actual list cells', async () => {
|
||||
const records = ([null, 'NO', 'YES'] as const).map((palletRequired, index) => ({ ...looseboard, id: String(100 + index), orderNo: `LB-NULL-${index}`, palletRequired, containerType: null, fleetArrangement: { ...arrangement, businessType: 'LOCAL_LOOSEBOARD' as const } }))
|
||||
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records, total: 3, page: 1, size: 20, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises()
|
||||
const rows = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')
|
||||
expect(rows.map(row => row.findAll('td')[3]!.text())).toEqual(['—', '否', '是'])
|
||||
expect(listLocalLooseboardOrders).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('shows an unknown pool container type as a dash without inventing 40HQ', async () => {
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, containerType: null, fleetArrangement: arrangement }], total: 1, page: 1, size: 20, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[7]!.text()).toBe('—')
|
||||
expect(wrapper.find('.el-table__body-wrapper').text()).not.toContain('40HQ')
|
||||
})
|
||||
it('still rejects an ordinary looseboard edit when its required pallet service is absent', async () => {
|
||||
routing.route.params = { orderId: looseboard.orderNo }; routing.route.query = { customerId: looseboard.customerId }
|
||||
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, fleetArrangement: null })
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'ElForm' }).props('model').palletRequired).toBeNull()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('保存修改'))!.trigger('click'); await flushPromises()
|
||||
expect(updateLocalLooseboardOrder).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('请选择是否打盘')
|
||||
})
|
||||
it('keeps all procurement facts in dense groups and business references in the heading', async () => {
|
||||
routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' }
|
||||
const wrapper = mountPage(PurchaseOrderDetailView)
|
||||
|
||||
@@ -17,6 +17,8 @@ import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '@/shared/domai
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
@@ -37,6 +39,7 @@ interface TransportFormModel {
|
||||
function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
|
||||
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
|
||||
const fleetArrangement = ref<FleetArrangement | null>(null)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
|
||||
const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' })
|
||||
@@ -54,9 +57,9 @@ const rules: FormRules<TransportFormModel> = {
|
||||
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
|
||||
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus])
|
||||
const editableStatus = computed(() => !editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW'))
|
||||
const editableStatus = computed(() => !fleetArrangement.value && (!editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW')))
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
function amount(value: string | null) { return value === null || value === '' ? null : Number(value) }
|
||||
@@ -68,16 +71,18 @@ async function loadCustomers() {
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
async function load() {
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null; fleetArrangement.value = null
|
||||
if (!editing.value) { captureBaseline(); return }
|
||||
if (!routeCustomerId.value) { loadError.value = { message: `缺少客户标识,请从${domainLabel.value}列表重新进入` }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
if (isLooseboard.value) {
|
||||
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
} else {
|
||||
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
}
|
||||
captureBaseline()
|
||||
@@ -86,6 +91,7 @@ async function load() {
|
||||
async function save() {
|
||||
if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return }
|
||||
if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return }
|
||||
if (isLooseboard.value && form.palletRequired !== 'YES' && form.palletRequired !== 'NO') { submitError.value = { message: '请选择是否打盘' }; return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
const common = { transportType: form.transportType as LocalTransportType, origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), containerType: optional(form.containerType), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, contactName: optional(form.contactName), contactInfo: optional(form.contactInfo), requestedDate: form.requestedDate, remark: optional(form.remark) }
|
||||
saving.value = true; submitError.value = null
|
||||
@@ -110,6 +116,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
|
||||
<AdminPage class="admin-density overseas-business-editor"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
|
||||
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
|
||||
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
|
||||
<FormSection class="density-form-section" v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
|
||||
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
|
||||
|
||||
@@ -9,6 +9,9 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
|
||||
@@ -23,9 +26,11 @@ const createPermission = computed(() => isLooseboard.value ? overseasBusinessPer
|
||||
const editPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.truckEdit)
|
||||
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
|
||||
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
|
||||
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
@@ -37,7 +42,7 @@ const truckColumns: DataTableColumn[] = [
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
|
||||
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90, formatter: (row: LocalTransportOrder) => row.containerType || '—' }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
|
||||
@@ -45,7 +50,7 @@ const truckColumns: DataTableColumn[] = [
|
||||
]
|
||||
const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : '否' },
|
||||
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : row.palletRequired === 'NO' ? '否' : '—' },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportTypeLabels[row.transportType] }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
|
||||
@@ -69,7 +74,7 @@ async function load() {
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW' }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
@@ -85,10 +90,10 @@ watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</ListPageLayout><AppDialog v-model="arrangementOpen" class="admin-density-dialog" title="承运安排" width="1080px" :show-confirm="false" cancel-text="关闭"><FleetArrangementSummary v-if="arrangement" :arrangement="arrangement" /></AppDialog></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>.table-panel { overflow:hidden; }</style>
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface LocalTransportQuery {
|
||||
transportType?: LocalTransportType | ''; customerId?: string; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalTransportOrder {
|
||||
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
|
||||
id: string; customerId: string; orderNo: string; transportType: LocalTransportType | null
|
||||
origin: string; destination: string; containerType: string | null; palletCount: number; packageCount: number
|
||||
receivedPalletCount: number; allocatedPalletCount: number; shippedPalletCount: number
|
||||
@@ -83,13 +84,14 @@ export interface UpdateLocalTransportOrderRequest {
|
||||
|
||||
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
|
||||
export type PalletRequired = 'YES' | 'NO'
|
||||
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
|
||||
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
|
||||
export interface LocalLooseboardQuery {
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
|
||||
status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalLooseboardOrder {
|
||||
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired
|
||||
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
|
||||
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired | null
|
||||
transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number
|
||||
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
|
||||
contactName: string | null; contactInfo: string | null; requestedDate: string | null; status: LocalLooseboardStatus
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 海外通用资料与车辆管理
|
||||
|
||||
- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
|
||||
- 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。
|
||||
|
||||
- 车次“在途交接”页签按需挂载`TripHandoverPanel.vue`,沿`fleet-handovers-v1.yaml`读取剩余货物和交接记录;确认用trip:handover,旧资源结束用trip:release,saved刷新车次。原DEPART不重写,司机/车辆任务分别结束。
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 海外通用资料原型核对矩阵
|
||||
|
||||
## 内部拼车需求池首版
|
||||
|
||||
依据:用户授权按现有车次样式整合;唯一范围为 [内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7、9节,没有另引入外部原型。
|
||||
|
||||
| 页面 | 操作 | 确认承载与实现 |
|
||||
| --- | --- | --- |
|
||||
| 拼车需求池 | 可接运力、我的意向、查询和分页 | 独立列表页,integrated DataTable,仅激活页签发请求;一行一发布,多站余位在行内分行 |
|
||||
| 发布详情 | 运输摘要、分段余位、获准意向 | 独立路由;一次详情含首20条意向,后续按需分页;销售不读取车次敏感DTO |
|
||||
| 意向 | 新增、编辑、只读详情 | 1080px居中三列表单;取消/X/Esc/路由离开保护;客户聚焦搜索和报价显式读取 |
|
||||
| 意向 | 确认承运、取消 | 现有居中确认框;服务端允许动作与独立权限;未知结果原键原载荷重试 |
|
||||
| 车次详情 | 发布设置 | 1080px居中三列表单,显式打开才读取;保存、发布、暂停、恢复、关闭各自命令 |
|
||||
| 车次外接预订 | 池来源编辑/确认/取消 | 只读并提示在池中管理;实际来货/实收/关联排车仍走原履约弹框 |
|
||||
|
||||
本轮用户授权内置浏览器1920×1080验收;自动测试与实际浏览器结果分别记录,未验收状态不推定通过。
|
||||
|
||||
## 2026-10-07 三个车辆列表样式统一
|
||||
|
||||
依据:本轮用户提供的司机管理、车次管理、出车记录三张截图;对齐已确认的 `VehiclesView.vue` 连续列表及 `docs/Admin高密度界面规范.md`。仅改列表容器与排版,不改变下列操作承载、权限或请求。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listCapacityOffers, listCapacityInquiries, listOfferCustomers, listOfferQuotes, normalizeInquiry, normalizeOffer, saveCapacityInquiry, commandCapacityInquiry } from './capacityOffers'
|
||||
import type { CapacityOffer, CapacityInquiry, InquiryWrite } from '../capacityOfferTypes'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
const offer = { offerNo: 'CO/1', tripNo: 'TR-1', recordVersion: '2', tripRecordVersion: '3', capacityKnown: false, destinations: [{ destinationKey: 'FBA:X', name: 'X', deliveryIndex: '1', remaining: null }], segments: [{ fromIndex: '0', toIndex: '1', fromName: '我仓', toName: 'X', planned: null, reserved: { palletSpaces: '0', weightKg: '0', volumeCbm: '0' }, remaining: null }] } as unknown as CapacityOffer
|
||||
const inquiry = { bookingNo: 'FB/1', offerNo: 'CO/1', recordVersion: '1', tripRecordVersion: '3', offerRecordVersion: '2', expectedPalletCount: null, agreedAmount: null, quoteSource: null, cargo: { packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: '0' } } as unknown as CapacityInquiry
|
||||
const envelope = <T,>(data: T) => ({ code: '200', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('keeps unknown quantities distinct from a known zero price and normalizes segment facts', () => {
|
||||
expect(normalizeInquiry(inquiry)).toMatchObject({ recordVersion: 1, expectedPalletCount: null, agreedAmount: null, cargo: { packageCount: null, unitPrice: 0 } })
|
||||
expect(normalizeOffer(offer)).toMatchObject({ capacityKnown: false, destinations: [{ remaining: null }], segments: [{ planned: null, reserved: { palletSpaces: 0 }, remaining: null }] })
|
||||
})
|
||||
it('uses only the dedicated pool endpoint for sales reads and forwards the requested page once', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [offer], total: '1', page: '2', size: '20', pages: '2' })).mockResolvedValueOnce(envelope({ records: [inquiry], total: '1', page: '1', size: '20', pages: '1' })).mockResolvedValueOnce(envelope([{ id: '9007199254740993', code: 'C-1', name: '测试客户' }]))
|
||||
await listCapacityOffers({ page: 2, size: 20 }); await listCapacityInquiries({ scope: 'MINE' }); const customers = await listOfferCustomers('测试')
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers', { params: { page: 2, size: 20 } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/inquiries', { params: { scope: 'MINE' } })
|
||||
expect(customers[0]?.id).toBe('9007199254740993'); expect(http.get).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
it('submits one command with encoded business numbers and an unchanged retry key', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(envelope({ offer, inquiry, replayed: false }))
|
||||
const body = { customerId: '19', idempotencyKey: 'stable-test-key', cargo: { packageCount: null } } as InquiryWrite
|
||||
await saveCapacityInquiry('CO/1', null, body)
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/inquiries', body)
|
||||
const command = { recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 3, idempotencyKey: 'confirm-stable-key' }
|
||||
await commandCapacityInquiry('FB/1', 'confirm', command)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/capacity-offers/inquiries/FB%2F1/confirm', command)
|
||||
})
|
||||
it('reads both quote pages from the offer-scoped URL and preserves the exact source version', async () => {
|
||||
const source = { quoteNo: 'FQ-POOL', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY', customerId: '19', customerName: '测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '35', currency: 'CNY', priceBasis: 'PALLET', quotedUnitPrice: '25.50', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [source], page: '1', size: '20', total: '21', pages: '2' })).mockResolvedValueOnce(envelope({ records: [{ ...source, quoteNo: 'FQ-POOL-SECOND' }], page: '2', size: '20', total: '21', pages: '2' }))
|
||||
const criteria = { customerId: '19', destinationKey: 'FBA:ONT8', currency: 'CNY' as const, priceBasis: 'PALLET' as const, page: 1, size: 20 }
|
||||
const first = await listOfferQuotes('CO/1', criteria)
|
||||
const second = await listOfferQuotes('CO/1', { ...criteria, page: 2 })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/quote-candidates', { params: criteria })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/quote-candidates', { params: { ...criteria, page: 2 } })
|
||||
expect(http.get).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(http.get).mock.calls.every(([url]) => !url.includes('/fleet/trips/'))).toBe(true)
|
||||
expect(first.records[0]).toMatchObject({ quoteNo: source.quoteNo, recordVersion: source.recordVersion, quotedUnitPrice: 25.5, distanceMiles: 35 })
|
||||
expect(second).toMatchObject({ page: 2, size: 20, total: 21, pages: 2, records: [{ quoteNo: 'FQ-POOL-SECOND', recordVersion: source.recordVersion }] })
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CapacityOffer, CapacityInquiry, CapacityOfferDetail, CapacityLoad, OfferPage, OfferQuery, InquiryQuery, OfferWrite, OfferCommand, InquiryWrite, InquiryCommand, OfferResult, InquiryResult, OfferCustomerOption } from '../capacityOfferTypes'
|
||||
import { normalizeFleetQuotePage, normalizeFleetQuoteSource, type WireQuotePage } from './fleetQuoteCandidates'
|
||||
import type { TripBookingQuoteCriteria } from './tripBookings'
|
||||
const base = '/platform/v1/last-leg/fleet/capacity-offers'
|
||||
const path = (value: string) => `${base}/${encodeURIComponent(value)}`
|
||||
const inquiryPath = (value: string) => `${base}/inquiries/${encodeURIComponent(value)}`
|
||||
const number = (value: number) => { const n = Number(value); if (value == null || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
|
||||
const nullable = (value: number | null) => value == null ? null : number(value)
|
||||
const load = (value: CapacityLoad | null) => value == null ? null : { palletSpaces: number(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) }
|
||||
export function normalizeOffer(value: CapacityOffer): CapacityOffer { return { ...value, recordVersion: number(value.recordVersion), tripRecordVersion: number(value.tripRecordVersion), destinations: value.destinations.map(d => ({ ...d, deliveryIndex: number(d.deliveryIndex), remaining: load(d.remaining) })), segments: value.segments.map(s => ({ ...s, fromIndex: number(s.fromIndex), toIndex: number(s.toIndex), planned: load(s.planned), reserved: load(s.reserved), remaining: load(s.remaining) })) } }
|
||||
export function normalizeInquiry(value: CapacityInquiry): CapacityInquiry { return { ...value, recordVersion: number(value.recordVersion), offerRecordVersion: number(value.offerRecordVersion), tripRecordVersion: number(value.tripRecordVersion), expectedPalletCount: nullable(value.expectedPalletCount), agreedAmount: nullable(value.agreedAmount), quoteSource: value.quoteSource ? normalizeFleetQuoteSource(value.quoteSource) : null, cargo: { ...value.cargo, packageCount: nullable(value.cargo.packageCount), palletSpaces: nullable(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } } }
|
||||
function page<T>(value: OfferPage<T>, normalize: (row: T) => T): OfferPage<T> { return { records: value.records.map(normalize), page: number(value.page), size: number(value.size), total: number(value.total), pages: number(value.pages) } }
|
||||
const offerResult = (value: OfferResult): OfferResult => ({ ...value, offer: normalizeOffer(value.offer) })
|
||||
const inquiryResult = (value: InquiryResult): InquiryResult => ({ ...offerResult(value), inquiry: normalizeInquiry(value.inquiry) })
|
||||
async function get<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
async function put<T>(url: string, body: object) { return unwrapApiResponse(await http.put<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
export async function listCapacityOffers(query: OfferQuery) { return page(await get<OfferPage<CapacityOffer>>(base, query), normalizeOffer) }
|
||||
export async function getCapacityOffer(offerNo: string): Promise<CapacityOfferDetail> { const value = await get<CapacityOfferDetail>(path(offerNo)); return { offer: normalizeOffer(value.offer), inquiries: page(value.inquiries, normalizeInquiry) } }
|
||||
export async function listCapacityInquiries(query: InquiryQuery) { return page(await get<OfferPage<CapacityInquiry>>(`${base}/inquiries`, query), normalizeInquiry) }
|
||||
export async function getCapacityInquiry(bookingNo: string) { return normalizeInquiry(await get<CapacityInquiry>(inquiryPath(bookingNo))) }
|
||||
export async function saveCapacityOffer(offerNo: string | null, body: OfferWrite) { return offerResult(await (offerNo ? put<OfferResult>(path(offerNo), body) : post<OfferResult>(base, body))) }
|
||||
export async function commandCapacityOffer(offerNo: string, action: 'publish' | 'pause' | 'resume' | 'close', body: OfferCommand) { return offerResult(await post<OfferResult>(`${path(offerNo)}/${action}`, body)) }
|
||||
export async function saveCapacityInquiry(offerNo: string, bookingNo: string | null, body: InquiryWrite) { return inquiryResult(await (bookingNo ? put<InquiryResult>(inquiryPath(bookingNo), body) : post<InquiryResult>(`${path(offerNo)}/inquiries`, body))) }
|
||||
export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/${action}`, body)) }
|
||||
export const listOfferCustomers = (keyword?: string) => get<OfferCustomerOption[]>(`${base}/customer-options`, { keyword })
|
||||
export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get<WireQuotePage>(`${path(offerNo)}/quote-candidates`, query)) }
|
||||
@@ -12,10 +12,11 @@ it('uses trip-scoped candidate criteria without accepting browser-computed city
|
||||
expect(value.records[0]).toMatchObject({ recordVersion: source.recordVersion, quotedUnitPrice: 0 })
|
||||
})
|
||||
it('normalizes quote source without overwriting negotiated price and handles manual absence', () => {
|
||||
const row: WireBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户', status: 'DRAFT', destinationLabel: 'ONT8', agreedAmount: '25.00', recordVersion: '1', updatedAt: '', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: source,
|
||||
const row: WireBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户', status: 'DRAFT', destinationLabel: 'ONT8', agreedAmount: '25.00', recordVersion: '1', updatedAt: '', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: source,
|
||||
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: '1', palletSpaces: '2', weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: '12.50', currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const value = normalizeBooking(row)
|
||||
expect(value.quoteSource).toMatchObject({ quotedUnitPrice: 0, recordVersion: source.recordVersion })
|
||||
expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25)
|
||||
expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull()
|
||||
expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } })
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { FleetCustomerOption, TripBooking, TripBookingCargo, TripBookingCre
|
||||
const base = '/platform/v1/last-leg/fleet/trips'
|
||||
const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings`
|
||||
type N = number | string
|
||||
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
|
||||
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource'> & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
|
||||
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
|
||||
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
|
||||
type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
|
||||
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
|
||||
type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
|
||||
@@ -13,8 +13,8 @@ const number = (value: N) => { const result = Number(value); if (value == null |
|
||||
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
|
||||
const nullable = (value: N | null) => value == null ? null : number(value)
|
||||
const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
packageCount: integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
|
||||
const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
|
||||
export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
|
||||
export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export interface FleetCustomerOption { id: string; code: string; name: string; contactName: string | null; contactPhone: string | null }
|
||||
export interface TripBookingCargo {
|
||||
destinationKey: string; cargoName: string | null; packageCount: number; palletSpaces: number | null;
|
||||
destinationKey: string; cargoName: string | null; packageCount: number | null; palletSpaces: number | null;
|
||||
weightKg: number | null; volumeCbm: number | null; priceBasis: 'PALLET' | 'ORDER'; unitPrice: number | null;
|
||||
currency: string | null; contactName: string | null; contactPhone: string | null; remark: string | null;
|
||||
}
|
||||
export interface TripBooking { bookingNo: string; tripNo: string; customerId: string; customerCode: string; customerName: string;
|
||||
export interface TripBooking { offerNo: string | null; expectedPalletCount: number | null; bookingNo: string; tripNo: string; customerId: string; customerCode: string; customerName: string;
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'; cargo: TripBookingCargo; destinationLabel: string;
|
||||
agreedAmount: number | null; recordVersion: number; updatedAt: string; incomingNo: string | null; dispatchNo: string | null; receipt: TripAcceptedReceipt | null; quoteSource: import('./api/fleetQuoteCandidates').FleetQuoteSourceSnapshot | null }
|
||||
export interface TripBookingCreate { tripRecordVersion: number; customerId: string; cargo: TripBookingCargo; idempotencyKey: string; quoteRef?: import('./api/fleetQuoteCandidates').FleetQuoteReference | null }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TripBookingCargo } from './bookingTypes'
|
||||
import type { FleetQuoteReference, FleetQuoteSourceSnapshot } from './api/fleetQuoteCandidates'
|
||||
|
||||
export interface OfferPage<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface CapacityLoad { palletSpaces: number; weightKg: number; volumeCbm: number }
|
||||
export interface OfferDestination { destinationKey: string; name: string; deliveryIndex: number; remaining: CapacityLoad | null }
|
||||
export interface OfferSegment { fromIndex: number; toIndex: number; fromName: string; toName: string; planned: CapacityLoad | null; reserved: CapacityLoad | null; remaining: CapacityLoad | null }
|
||||
export interface CapacityOffer {
|
||||
offerNo: string; tripNo: string; status: 'DRAFT' | 'OPEN' | 'PAUSED' | 'CLOSED'; recordVersion: number; tripRecordVersion: number
|
||||
warehouseId: string; warehouseName: string; plannedDepartureAt: string; cutoffAt: string
|
||||
contactName: string | null; contactPhone: string | null; remark: string | null
|
||||
destinations: OfferDestination[]; segments: OfferSegment[]; capacityKnown: boolean; canCreateInquiry: boolean; canConfirm: boolean; blockReason: string | null
|
||||
actions: { canEdit: boolean; canPublish: boolean; canPause: boolean; canResume: boolean; canClose: boolean }
|
||||
}
|
||||
export interface CapacityInquiry {
|
||||
bookingNo: string; offerNo: string; tripNo: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'
|
||||
recordVersion: number; offerRecordVersion: number; tripRecordVersion: number; salesOwnerUserId: string
|
||||
customerId: string; customerCode: string; customerName: string; businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
|
||||
deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE'; estimatedArrivalAt: string | null; expectedPalletCount: number | null
|
||||
cargo: TripBookingCargo; destinationLabel: string; agreedAmount: number | null; quoteSource: FleetQuoteSourceSnapshot | null
|
||||
businessOrderNo: string | null; incomingNo: string | null; dispatchNo: string | null; updatedAt: string
|
||||
actions: { canEdit: boolean; canConfirm: boolean; canCancel: boolean; blockReason: string | null }
|
||||
}
|
||||
export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage<CapacityInquiry> }
|
||||
export interface OfferQuery { page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
|
||||
export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' }
|
||||
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
|
||||
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
|
||||
export interface InquiryWrite {
|
||||
tripRecordVersion: number; offerRecordVersion: number; recordVersion: number | null; customerId: string
|
||||
businessType: CapacityInquiry['businessType']; deliveryMode: CapacityInquiry['deliveryMode']; estimatedArrivalAt: string | null
|
||||
expectedPalletCount: number | null; cargo: TripBookingCargo; quoteRef: FleetQuoteReference | null; idempotencyKey: string
|
||||
}
|
||||
export interface InquiryCommand extends OfferCommand { recordVersion: number }
|
||||
export interface OfferResult { offer: CapacityOffer; replayed: boolean }
|
||||
export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
|
||||
export interface OfferCustomerOption { id: string; code: string; name: string }
|
||||
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
|
||||
export const inquiryStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' }
|
||||
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
|
||||
@@ -0,0 +1,105 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
|
||||
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
|
||||
import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue'
|
||||
import type { CapacityOffer } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { getCapacityOffer, listCapacityOffers } from '../api/capacityOffers'
|
||||
import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), listCapacityInquiries: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn(), commandCapacityInquiry: vi.fn() }))
|
||||
const offer: CapacityOffer = { offerNo: 'CO-CLOSE', tripNo: 'TR-CLOSE', status: 'OPEN', recordVersion: 1, tripRecordVersion: 2, warehouseId: '9', warehouseName: '测试仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待核对', actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }); vi.mocked(getCapacityOffer).mockResolvedValue({ offer, inquiries: { records: [], total: 0, page: 1, size: 20, pages: 0 } }) })
|
||||
afterEach(async () => { ElMessageBox.close(); wrappers.splice(0).forEach(wrapper => wrapper.unmount()); await flushPromises(); document.body.innerHTML = ''; vi.restoreAllMocks() })
|
||||
function mountDialog(kind: 'inquiry' | 'settings') {
|
||||
const pinia = createPinia()
|
||||
const wrapper = kind === 'inquiry'
|
||||
? mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [pinia, ElementPlus] } })
|
||||
: mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus] } })
|
||||
wrappers.push(wrapper)
|
||||
return { wrapper, pinia }
|
||||
}
|
||||
async function continueEditing() {
|
||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||
expect(message).not.toBeNull()
|
||||
const button = [...message.querySelectorAll('button')].find(node => node.textContent?.trim() === '继续填写')!
|
||||
expect(button).toBeTruthy(); button.click(); await flushPromises()
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); await flushPromises()
|
||||
}
|
||||
it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited input visible through cancel, Escape and X vetoes', async kind => {
|
||||
const { wrapper, pinia } = mountDialog(kind)
|
||||
if (kind === 'settings') await wrapper.setProps({ modelValue: true })
|
||||
await flushPromises()
|
||||
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
await input.setValue('UI-未保存检查'); await flushPromises()
|
||||
const assertPreserved = () => {
|
||||
expect(wrapper.find('.app-dialog').isVisible()).toBe(true)
|
||||
expect(input.element.isConnected).toBe(true)
|
||||
expect(input.element.value).toBe('UI-未保存检查')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1)
|
||||
}
|
||||
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
|
||||
assertPreserved(); await continueEditing(); assertPreserved()
|
||||
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises()
|
||||
assertPreserved(); await continueEditing(); assertPreserved()
|
||||
await wrapper.get('.el-dialog__headerbtn').trigger('click'); await flushPromises()
|
||||
assertPreserved(); await continueEditing(); assertPreserved()
|
||||
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
|
||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||
const accept = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '确认关闭') as HTMLButtonElement
|
||||
accept.click(); await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => {
|
||||
const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm')
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
|
||||
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } },
|
||||
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, meta: { workspaceTab: false } },
|
||||
] })
|
||||
// Match the application's intentional policy: cross-workspace switching skips the global prompt.
|
||||
router.beforeEach(async (to, from) => {
|
||||
const fromId = workspaceRouteId(from)
|
||||
if (!fromId || isWorkspaceRoute(to) && workspaceRouteId(to) !== fromId || !registry.findForWorkspace(fromId).length) return true
|
||||
try { await ElMessageBox.confirm('当前页面有未保存内容', '全局离开确认', { confirmButtonText: '放弃并继续', cancelButtonText: '继续填写' }); registry.removeWorkspaces([fromId]); return true } catch { return false }
|
||||
})
|
||||
const Host = defineComponent({ components: { RouterView }, template: '<RouterView v-slot="{ Component, route }"><KeepAlive><component :is="Component" :key="route.name" /></KeepAlive></RouterView>' })
|
||||
await router.push('/pool'); await router.isReady()
|
||||
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
|
||||
await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
|
||||
const input = wrapper.get<HTMLInputElement>('input[placeholder="选填"]')
|
||||
await input.setValue('UI-修复后保留输入'); await flushPromises()
|
||||
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises(); await continueEditing()
|
||||
confirms.mockClear()
|
||||
router.back(); await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true)
|
||||
await continueEditing()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
confirms.mockClear(); router.back(); await flushPromises()
|
||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||
const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement
|
||||
expect(leave).toBeTruthy(); leave.click(); await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get<HTMLInputElement>('input[placeholder="选填"]').setValue('另一次修改'); await flushPromises()
|
||||
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
|
||||
await continueEditing(); await navigation
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import ElementPlus, { ElMessage } from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
|
||||
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
|
||||
import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes'
|
||||
import type { FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
|
||||
const permissions = vi.hoisted(() => ({ values: new Set<string>() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permissions.values.has(permission) }) }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn() }))
|
||||
const offer: CapacityOffer = { offerNo: 'CO-QUOTE-POSITIVE', tripNo: 'TR-QUOTE-POSITIVE', status: 'OPEN', recordVersion: 4, tripRecordVersion: 7, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: { palletSpaces: 8, weightKg: 1000, volumeCbm: 20 } }], segments: [], capacityKnown: true, canCreateInquiry: true, canConfirm: true, blockReason: null, actions: { canEdit: false, canPublish: false, canPause: false, canResume: false, canClose: false } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(listOfferCustomers).mockResolvedValue([{ id: '19', code: 'C-19', name: '报价测试客户' }])
|
||||
})
|
||||
afterEach(async () => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); ElMessage.closeAll(); await flushPromises(); document.body.innerHTML = '' })
|
||||
function formItem(wrapper: VueWrapper, label: string) { return wrapper.findAll('.el-form-item').find(item => item.find('.el-form-item__label').text() === label)! }
|
||||
async function select(wrapper: VueWrapper, label: string, value: string) {
|
||||
formItem(wrapper, label).findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
await flushPromises()
|
||||
}
|
||||
const scenarios = [
|
||||
{ actor: 'sales', priceBasis: 'PALLET' as const, permissions: ['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:quote:list'] },
|
||||
{ actor: 'manager', priceBasis: 'ORDER' as const, permissions: ['platform:fleet:capacity-offer:manage', 'platform:fleet:quote:list'] },
|
||||
]
|
||||
it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated-price saving for $actor', async scenario => {
|
||||
permissions.values = new Set(scenario.permissions)
|
||||
const source: FleetQuoteSourceSnapshot = { quoteNo: 'FQ-PAGE-2', recordVersion: '9007199254740993', priceItem: scenario.priceBasis === 'ORDER' ? 'FULL_DELIVERY' : 'LOOSE_DELIVERY', customerId: '19', customerName: '报价测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 35, currency: 'CNY', priceBasis: scenario.priceBasis, quotedUnitPrice: 25.5, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
|
||||
vi.mocked(listOfferQuotes).mockResolvedValueOnce({ records: Array.from({ length: 20 }, (_, index) => ({ ...source, quoteNo: `FQ-PAGE-1-${index + 1}` })), total: 21, page: 1, size: 20, pages: 2 }).mockResolvedValueOnce({ records: [source], total: 21, page: 2, size: 20, pages: 2 })
|
||||
vi.mocked(saveCapacityInquiry).mockImplementation(async (_offerNo, _bookingNo, body) => {
|
||||
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
|
||||
return { offer, inquiry, replayed: false }
|
||||
})
|
||||
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
expect(listOfferQuotes).not.toHaveBeenCalled()
|
||||
await select(wrapper, '委托客户', '19'); await select(wrapper, '本车卸货站', 'FBA:ONT8'); await select(wrapper, '币种', 'CNY'); await select(wrapper, '计价方式', scenario.priceBasis)
|
||||
expect(listOfferQuotes).not.toHaveBeenCalled()
|
||||
const quotePanel = wrapper.get('section[aria-label="报价来源"]')
|
||||
await quotePanel.findAll('button').find(button => button.text() === '查询报价库')!.trigger('click'); await flushPromises()
|
||||
const criteria = { customerId: '19', destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, page: 1, size: 20 }
|
||||
expect(listOfferQuotes).toHaveBeenCalledExactlyOnceWith(offer.offerNo, criteria)
|
||||
await quotePanel.findAll('button').find(button => button.text() === '下一页')!.trigger('click'); await flushPromises()
|
||||
expect(listOfferQuotes).toHaveBeenNthCalledWith(2, offer.offerNo, { ...criteria, page: 2 })
|
||||
const reference = { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }
|
||||
quotePanel.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', `${source.quoteNo}:${source.recordVersion}:${source.priceItem}`); await flushPromises()
|
||||
const price = formItem(wrapper, scenario.priceBasis === 'PALLET' ? '每托位单价' : '整单价格').findComponent({ name: 'ElInputNumber' })
|
||||
expect(price.props('modelValue')).toBe(25.5)
|
||||
expect(quotePanel.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe(`${source.quoteNo}:${source.recordVersion}:${source.priceItem}`)
|
||||
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
|
||||
await price.get('input').setValue('21.75'); await flushPromises()
|
||||
expect(price.props('modelValue')).toBe(21.75)
|
||||
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
|
||||
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存意向')!.trigger('click'); await flushPromises()
|
||||
expect(saveCapacityInquiry).toHaveBeenCalledExactlyOnceWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', offerRecordVersion: 4, tripRecordVersion: 7, recordVersion: null, quoteRef: reference, idempotencyKey: expect.any(String), cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, unitPrice: 21.75 }) }))
|
||||
expect(listOfferQuotes).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, toRaw, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
|
||||
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
|
||||
import type { TripBookingCargo } from '../bookingTypes'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean }>()
|
||||
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: [] }>()
|
||||
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
|
||||
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
|
||||
const customerError = ref<ApiErrorDetails | null>(null), customerSearched = ref(false)
|
||||
const quoteRows = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null), quoteSearched = ref(false), quotePage = ref(1), quotePages = ref(0)
|
||||
const pending = ref<{ target: string; body: InquiryWrite } | null>(null)
|
||||
const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo || '')
|
||||
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
|
||||
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
|
||||
const lock = computed(() => !writable.value || busy.value || !!pending.value)
|
||||
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
|
||||
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
|
||||
let generation = 0, customerSequence = 0, quoteSequence = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
function resetQuotes() { quoteSequence++; quoteRows.value = []; quoteSearched.value = false; quoteError.value = null; quoteLoading.value = false; quotePage.value = 1; quotePages.value = 0 }
|
||||
watch(() => [form.customerId, form.cargo.destinationKey, form.cargo.currency, form.cargo.priceBasis], () => { resetQuotes(); form.quoteRef = null; quoteSource.value = null }, { flush: 'sync' })
|
||||
watch(() => [open.value, target.value], async () => {
|
||||
generation++; customerSequence++; clearTimeout(timer); resetQuotes(); customerLoading.value = false
|
||||
if (!open.value) return
|
||||
if (pending.value && pending.value.target !== target.value) { ElMessage.warning('请先核对或重试上一条结果未知的意向'); open.value = false; return }
|
||||
error.value = null; customerError.value = null; customerSearched.value = false; customers.value = []
|
||||
if (pending.value) Object.assign(form, structuredClone(toRaw(pending.value.body)))
|
||||
else {
|
||||
const row = props.inquiry
|
||||
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
|
||||
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
|
||||
}
|
||||
await nextTick(); guard.capture()
|
||||
}, { immediate: true })
|
||||
function searchCustomers(keyword = '') {
|
||||
if (lock.value || props.inquiry) return
|
||||
clearTimeout(timer); const current = ++customerSequence
|
||||
timer = setTimeout(async () => {
|
||||
customerLoading.value = true; customerError.value = null
|
||||
try { const rows = await listOfferCustomers(keyword.trim() || undefined); if (current === customerSequence && open.value) { customers.value = rows; customerSearched.value = true } }
|
||||
catch (cause) { if (current === customerSequence) { customers.value = []; customerError.value = getApiErrorDetails(cause, '客户候选加载失败') } }
|
||||
finally { if (current === customerSequence) customerLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
async function queryQuotes(page = 1) {
|
||||
if (!props.offer || lock.value || !canQuote.value || !quoteReady.value) return
|
||||
const current = ++quoteSequence; quoteLoading.value = true; quoteError.value = null
|
||||
try { const value = await listOfferQuotes(props.offer.offerNo, { customerId: form.customerId, destinationKey: form.cargo.destinationKey, currency: form.cargo.currency as 'USD' | 'CNY', priceBasis: form.cargo.priceBasis, page, size: 20 }); if (current === quoteSequence && open.value) { quoteRows.value = value.records; quotePage.value = value.page; quotePages.value = value.pages; quoteSearched.value = true } }
|
||||
catch (cause) { if (current === quoteSequence) quoteError.value = getApiErrorDetails(cause, '报价候选读取失败,可继续手工询价') }
|
||||
finally { if (current === quoteSequence) quoteLoading.value = false }
|
||||
}
|
||||
const sourceKey = (row: FleetQuoteReference) => `${row.quoteNo}:${row.recordVersion}:${row.priceItem}`
|
||||
function selectQuote(key: string) { const row = quoteRows.value.find(item => sourceKey(item) === key); if (row && !lock.value) { quoteSource.value = row; form.quoteRef = fleetQuoteReference(row); form.cargo.unitPrice = row.quotedUnitPrice } }
|
||||
async function save() {
|
||||
if (busy.value || !writable.value || !props.offer) return
|
||||
if (!form.customerId || !form.cargo.destinationKey) { ElMessage.warning('请选择客户和本车卸货站'); return }
|
||||
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
pending.value ||= { target: target.value, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
|
||||
const result = await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
|
||||
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success('意向已保存,尚未占用运力') }
|
||||
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
|
||||
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
|
||||
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
|
||||
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
|
||||
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
|
||||
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
||||
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
|
||||
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
|
||||
<el-form-item label="预计到仓"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="计价方式"><el-select v-model="form.cargo.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
|
||||
<el-form-item :label="form.cargo.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.cargo.unitPrice" :min="0" :precision="2" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="币种"><el-select v-model="form.cargo.currency" filterable allow-create><el-option v-for="currency in ['USD', 'CNY', 'CAD', 'EUR', 'GBP']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="form.cargo.contactName" placeholder="确认承运前补充" /></el-form-item>
|
||||
<el-form-item label="联系方式"><el-input v-model="form.cargo.contactPhone" placeholder="确认承运前补充" /></el-form-item>
|
||||
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="customerError" title="客户候选读取失败" :message="customerError.message" @retry="searchCustomers()"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
|
||||
<section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
|
||||
<template v-if="canQuote && writable"><el-button :disabled="lock || !quoteReady" :loading="quoteLoading" @click="queryQuotes()">查询报价库</el-button><el-button v-if="form.quoteRef" :disabled="lock" @click="form.quoteRef = null; quoteSource = null">改为手工询价</el-button>
|
||||
<el-select v-if="quoteRows.length" :model-value="form.quoteRef ? sourceKey(form.quoteRef) : ''" :disabled="lock" placeholder="选择报价,可另行议价" @update:model-value="selectQuote"><el-option v-for="quote in quoteRows" :key="sourceKey(quote)" :value="sourceKey(quote)" :label="`${quote.quoteNo} · ${quote.quotedUnitPrice} ${quote.currency}`" /></el-select>
|
||||
<p v-if="quoteSearched && !quoteRows.length">暂无适用报价,可继续手工询价。</p><div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
|
||||
</template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p>
|
||||
</section>
|
||||
<p v-if="inquiry">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { inquiryStatusLabels, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
|
||||
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
|
||||
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
|
||||
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
const manage = access.can('platform:fleet:capacity-offer:manage')
|
||||
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
|
||||
return [{ key: 'view', label: '详情', priority: 10 },
|
||||
{ key: 'edit', label: '编辑', visible: row.actions.canEdit && write('edit'), disabled: busy },
|
||||
{ key: 'confirm', label: '确认承运', visible: row.actions.canConfirm && manage, disabled: busy },
|
||||
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210 }, { key: 'customerName', label: '客户', minWidth: 155 },
|
||||
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
|
||||
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
|
||||
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
|
||||
]
|
||||
</script>
|
||||
<template><DataTable :appearance="integrated ? 'integrated' : 'default'" :stripe="false" :results-visible="!failed" preference-key="fleet-capacity-inquiries" column-settings-mode="show" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="bookingNo" empty-text="当前范围暂无意向">
|
||||
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
|
||||
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
|
||||
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||
<template #status="{ row }">{{ inquiryStatusLabels[(row as CapacityInquiry).status] }}<small v-if="row.actions.blockReason" class="inquiry-reason">{{ row.actions.blockReason }}</small></template>
|
||||
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
|
||||
</DataTable></template>
|
||||
<style scoped>.inquiry-reason{display:block;color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal}</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listCapacityOffers, saveCapacityOffer, commandCapacityOffer } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, type CapacityOffer, type OfferWrite, type OfferCommand } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: FleetTrip }>()
|
||||
const emit = defineEmits<{ saved: [version: number] }>()
|
||||
const access = useAccessPreviewStore(), offer = ref<CapacityOffer | null>(null), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const loaded = ref(false)
|
||||
const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark: '' })
|
||||
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
|
||||
let generation = 0
|
||||
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, computed(() => !!pending.value))
|
||||
async function load() {
|
||||
if (!writable.value || !open.value || pending.value) return
|
||||
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
|
||||
try { const value = await listCapacityOffers({ tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
watch(open, value => { generation++; if (value) { if (pending.value) return; void load() } })
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value || !writable.value) return
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try { const p = pending.value; const result = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body); if (current !== generation) return; offer.value = result.offer; pending.value = null; emit('saved', result.offer.tripRecordVersion); guard.capture(); ElMessage.success('发布设置已更新') }
|
||||
catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '发布操作未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (pending.value) { await execute(); return }
|
||||
if (loading.value || !loaded.value || !writable.value || offer.value && !offer.value.actions.canEdit) return
|
||||
if (!form.cutoffAt) { ElMessage.warning('请填写交货截止时间'); return }
|
||||
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: props.trip.tripNo, tripRecordVersion: offer.value?.tripRecordVersion ?? props.trip.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
|
||||
await execute()
|
||||
}
|
||||
async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
|
||||
if (!offer.value || loading.value || !loaded.value || busy.value || pending.value || !writable.value) return
|
||||
if (guard.dirty.value) { ElMessage.warning('请先保存发布信息,再执行发布状态操作'); return }
|
||||
const labels = { publish: '发布运力', pause: '暂停接单', resume: '恢复接单', close: '关闭发布' }
|
||||
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
pending.value = { action, offerNo: offer.value.offerNo, body: { tripRecordVersion: offer.value.tripRecordVersion, offerRecordVersion: offer.value.recordVersion, idempotencyKey: crypto.randomUUID() } }; await execute()
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布设置" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '保存发布信息'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
|
||||
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">
|
||||
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /></el-form-item><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPublish" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('publish')">发布运力</el-button><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canResume" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('resume')">重新核对并恢复</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
|
||||
</AppDialog></template>
|
||||
<style scoped>.offer-settings-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.offer-settings-wide{grid-column:1/-1}.offer-settings-form :deep(.el-date-editor){width:100%}</style>
|
||||
@@ -7,7 +7,7 @@ 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,
|
||||
const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
|
||||
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
|
||||
pallets: [{ warehousePalletId: '902', palletNo: 'PLT-1', packages: 22, weightKg: 150, volumeCbm: 1.5 }] } }
|
||||
|
||||
@@ -9,7 +9,7 @@ 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',
|
||||
const row: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
|
||||
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip
|
||||
const wrappers: VueWrapper[] = []
|
||||
@@ -27,6 +27,16 @@ function mount(realNumber?: Component, realInput?: Component) {
|
||||
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.writable = true; access.quote = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('shows pool inquiries read-only without legacy edit, confirm or cancel actions', async () => {
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
|
||||
const wrapper = mount(); await flushPromises()
|
||||
for (const label of ['编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(node => node.text() === label)).toBe(false)
|
||||
await button(wrapper, '详情').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('请从拼车需求池管理询价与承运')
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false)
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.confirmTripBooking).not.toHaveBeenCalled()
|
||||
})
|
||||
it('confirms using both current versions and updates the parent version without extra list reads', async () => {
|
||||
vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises()
|
||||
|
||||
@@ -24,7 +24,7 @@ const readOnly = ref(false)
|
||||
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
|
||||
const pendingCreate = ref<TripBookingCreate | null>(null), pendingCommand = ref<{ bookingNo: string; action: 'confirm' | 'cancel'; body: TripBookingCommand } | null>(null)
|
||||
const version = ref<number | null>(null), customerId = ref('')
|
||||
const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: 1, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
const form = reactive<TripBookingCargo>(blank())
|
||||
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), quoteRef = ref<FleetQuoteReference | null>(null)
|
||||
const candidates = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -83,7 +83,7 @@ function searchCustomers(keyword = '') {
|
||||
}, 250)
|
||||
}
|
||||
function edit(row?: TripBooking, viewOnly = false) {
|
||||
readOnly.value = viewOnly
|
||||
readOnly.value = viewOnly || !!row?.offerNo
|
||||
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
|
||||
editing.value = row || null
|
||||
resetCandidates()
|
||||
@@ -102,9 +102,9 @@ function failure(cause: unknown, fallback: string) {
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pendingCreate.value = null; pendingCommand.value = null }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || readOnly.value || version.value == null) return
|
||||
if (busy.value || readOnly.value || editing.value?.offerNo || version.value == null) return
|
||||
if (editing.value?.quoteSource && !canReadQuotes.value) { ElMessage.warning('编辑带报价来源的询价需要报价读取权限'); return }
|
||||
if (!customerId.value || !form.destinationKey || !form.packageCount) { ElMessage.warning('请选择客户、卸货站并填写箱数'); return }
|
||||
if (!customerId.value || !form.destinationKey) { ElMessage.warning('请选择客户和卸货站'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
let value: TripBookingResult
|
||||
@@ -118,7 +118,7 @@ async function save() {
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function command(row: TripBooking, action: 'confirm' | 'cancel') {
|
||||
if (busy.value || version.value == null) return
|
||||
if (busy.value || row.offerNo || version.value == null) return
|
||||
if (pendingCommand.value && (pendingCommand.value.bookingNo !== row.bookingNo || pendingCommand.value.action !== action)) { ElMessage.warning('请先重试上一条操作或刷新核对'); return }
|
||||
const current = generation
|
||||
if (!pendingCommand.value) {
|
||||
@@ -145,18 +145,18 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
|
||||
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
|
||||
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
|
||||
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" destroy-on-close :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
|
||||
<el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
|
||||
<p v-if="editing?.offerNo">池来源意向 {{ editing.offerNo }},请从拼车需求池管理询价与承运。</p><el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
|
||||
<el-form-item label="委托客户" required><el-select v-model="customerId" filterable remote :remote-method="searchCustomers" :loading="customersLoading" :disabled="!!editing" placeholder="按客户名称、编号搜索" @focus="searchCustomers()"><el-option v-if="editing" :value="editing.customerId" :label="editing.customerName" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.destinationKey" placeholder="选择顺路卸货站"><el-option v-for="stop in trip.stops.slice(1).filter(row => row.destinationKey)" :key="stop.destinationKey!" :value="stop.destinationKey!" :label="stop.name" /></el-select></el-form-item>
|
||||
<el-form-item label="货物名称"><el-input v-model="form.cargoName" placeholder="填写货物名称(选填)" /></el-form-item>
|
||||
<el-form-item label="箱数" required><el-input-number v-model="form.packageCount" :min="1" :precision="0" placeholder="预计箱数" /></el-form-item>
|
||||
<el-form-item label="预计箱数"><el-input-number v-model="form.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="预计标准托位"><el-input-number v-model="form.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import { getCapacityOffer, getCapacityInquiry, listCapacityInquiries } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, offerTime, loadText, type CapacityOfferDetail, type CapacityInquiry, type InquiryResult } from '../capacityOfferTypes'
|
||||
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<CapacityOfferDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), inquiryError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), readonly = ref(false)
|
||||
let sequence = 0, inquirySequence = 0, resetting = false
|
||||
const canCreate = computed(() => detail.value?.offer.canCreateInquiry && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')))
|
||||
const columns: DataTableColumn[] = [{ key: 'fromName', label: '起始站', minWidth: 150 }, { key: 'toName', label: '到达站', minWidth: 150 }, { key: 'planned', label: '已关联货物计划占用', minWidth: 260, slotName: 'planned' }, { key: 'reserved', label: '未关联外接预留', minWidth: 260, slotName: 'reserved' }, { key: 'remaining', label: '可接托位 / 重量 / 体积', minWidth: 270, slotName: 'remaining' }]
|
||||
async function load() {
|
||||
const current = ++sequence; inquirySequence++; loading.value = true; error.value = null
|
||||
try {
|
||||
const value = await getCapacityOffer(String(route.params.offerNo))
|
||||
let checked: CapacityInquiry | null = null
|
||||
if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
|
||||
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
|
||||
}
|
||||
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function loadInquiries() {
|
||||
if (!detail.value || resetting) return
|
||||
const current = ++inquirySequence; inquiryLoading.value = true; inquiryError.value = null
|
||||
try { const value = await listCapacityInquiries({ offerNo: detail.value.offer.offerNo, page: page.value, size: size.value, scope: access.can('platform:fleet:capacity-offer:manage') ? 'ALL' : 'MINE' }); if (current === inquirySequence && detail.value) detail.value.inquiries = value }
|
||||
catch (cause) { if (current === inquirySequence) inquiryError.value = getApiErrorDetails(cause, '意向列表读取失败') }
|
||||
finally { if (current === inquirySequence) inquiryLoading.value = false }
|
||||
}
|
||||
function accept(value: InquiryResult) {
|
||||
if (!detail.value) return
|
||||
detail.value.offer = value.offer
|
||||
const rows = detail.value.inquiries.records, index = rows.findIndex(row => row.bookingNo === value.inquiry.bookingNo)
|
||||
if (index >= 0) rows[index] = value.inquiry
|
||||
else if (page.value === 1) { rows.unshift(value.inquiry); if (rows.length > size.value) rows.pop(); detail.value.inquiries.total++ }
|
||||
else void loadInquiries()
|
||||
for (const row of rows) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
|
||||
}
|
||||
const commands = useCapacityInquiryCommands('capacity-offer-command', accept)
|
||||
async function action(key: string, row: CapacityInquiry) { if (key === 'confirm' || key === 'cancel') await commands.run(row, key); else { editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true } }
|
||||
function create() { if (!canCreate.value) return; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
watch(() => [route.name, route.params.offerNo], ([name, offerNo]) => {
|
||||
if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return
|
||||
detail.value = null; editorOpen.value = false; void load()
|
||||
}, { immediate: true })
|
||||
watch([page, size], loadInquiries)
|
||||
onBeforeUnmount(() => { sequence++; inquirySequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车需求池</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="canCreate" type="primary" :disabled="loading" @click="create">登记意向</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>交货截止</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
<p>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记意向不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
||||
<h2 class="capacity-section-title">分段运力</h2><DataTable :data="detail.offer.segments" :columns="columns" :stripe="false" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable>
|
||||
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的意向' }}</h2>
|
||||
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
|
||||
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
|
||||
</template></div>
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="load" />
|
||||
</DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import CapacityOffersView from './CapacityOffersView.vue'
|
||||
import CapacityOfferDetailView from './CapacityOfferDetailView.vue'
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import CapacityOfferSettingsDialog from '../components/CapacityOfferSettingsDialog.vue'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import * as api from '../api/capacityOffers'
|
||||
import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, route: { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
|
||||
export const offer: CapacityOffer = { offerNo: 'CO-1', tripNo: 'TR-1', status: 'OPEN', recordVersion: 2, tripRecordVersion: 5, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-10T12:00:00Z', cutoffAt: '2026-10-10T10:00:00Z', contactName: '调度', contactPhone: '测试联系信息', remark: null, capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待调度核对', destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
|
||||
export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
|
||||
const pageOf = <T,>(records: T[], total = records.length) => ({ records, total, page: 1, size: 20, pages: Math.ceil(total / 20) })
|
||||
const wrappers: VueWrapper[] = []
|
||||
const layout = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '<input />' }
|
||||
function mount(component: object, props: Record<string, unknown> = {}) {
|
||||
const pinia = createPinia()
|
||||
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
|
||||
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
|
||||
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="loads" :row="row"/></div><slot/></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '<div/>' },
|
||||
CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' },
|
||||
CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true,
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmText', 'beforeClose'], emits: ['update:modelValue', 'confirm'], template: '<div v-if="modelValue"><slot/></div>' },
|
||||
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' }, ElForm: layout, ElFormItem: layout,
|
||||
ElInput: { ...field, name: 'ElInput' }, ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: { ...field, name: 'ElDatePicker' }, ElOption: true, ApiErrorMeta: true, LoadErrorState: true,
|
||||
} } }); wrappers.push(wrapper); return { wrapper, pinia }
|
||||
}
|
||||
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
|
||||
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
|
||||
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('待调度核对')
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
|
||||
await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled()
|
||||
const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' }))
|
||||
})
|
||||
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
|
||||
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
|
||||
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
resolve(pageOf([offer], 900)); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1)
|
||||
vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('failed')).toBe(true)
|
||||
})
|
||||
it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => {
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
await button(wrapper, '登记意向').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true)
|
||||
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => {
|
||||
vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true })
|
||||
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', inquiry.customerId); selects[2]!.vm.$emit('update:modelValue', inquiry.cargo.destinationKey); await flushPromises()
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.saveCapacityInquiry).mock.calls[0]![2]
|
||||
expect(first).toMatchObject({ expectedPalletCount: null, cargo: { packageCount: null, palletSpaces: null }, idempotencyKey: expect.any(String) })
|
||||
expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, null, first)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); expect(api.listOfferCustomers).not.toHaveBeenCalled()
|
||||
})
|
||||
it('protects X/Esc/cancel model-close requests and does not emit close when discard is declined', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry, readonly: false }); await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElInput' })[0]!.vm.$emit('update:modelValue', '改名'); await flushPromises()
|
||||
const done = vi.fn()
|
||||
await wrapper.findComponent({ name: 'AppDialog' }).props('beforeClose')(done); await flushPromises()
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledOnce(); expect(done).not.toHaveBeenCalled(); expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
it('retains an uncertain request and route protection after closing and reopening its form', async () => {
|
||||
vi.mocked(api.saveCapacityInquiry).mockRejectedValue(new Error('network'))
|
||||
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry, readonly: false }); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.saveCapacityInquiry).mock.calls[0]![2]
|
||||
await (wrapper as VueWrapper<Record<string, unknown>>).setProps({ modelValue: false }); await flushPromises()
|
||||
expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1)
|
||||
await (wrapper as VueWrapper<Record<string, unknown>>).setProps({ modelValue: true }); await flushPromises()
|
||||
expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1)
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, inquiry.bookingNo, first)
|
||||
})
|
||||
it('keeps sales from confirming even if a stale row advertises the action', async () => {
|
||||
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
it('loads publishing settings only when explicitly opened, not with the trip page', async () => {
|
||||
state.manage = true
|
||||
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
|
||||
expect(api.listCapacityOffers).not.toHaveBeenCalled(); await (wrapper as VueWrapper<Record<string, unknown>>).setProps({ modelValue: true }); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ tripNo: 'TR-1', page: 1, size: 1 })
|
||||
})
|
||||
it('retries an uncertain dispatch confirmation with the same command and no second confirmation box', async () => {
|
||||
state.manage = true
|
||||
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
|
||||
vi.mocked(api.commandCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: true })
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
|
||||
const first = vi.mocked(api.commandCapacityInquiry).mock.calls[0]!
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(api.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
|
||||
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const tab = ref<'offers' | 'inquiries'>('offers'), filters = ref<QueryModel>({}), applied = ref<QueryModel>({})
|
||||
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
|
||||
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
|
||||
let sequence = 0, editorSequence = 0
|
||||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
||||
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
|
||||
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
|
||||
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
|
||||
...(manage.value ? [{ key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
|
||||
] : [
|
||||
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
|
||||
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的意向', options: [{ value: 'MINE', label: '我的意向' }, { value: 'ALL', label: '全部意向' }] } as QueryField] : []),
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'offerNo', label: '发布号 / 车次号', minWidth: 220, slotName: 'identity' },
|
||||
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
|
||||
{ key: 'plannedDepartureAt', label: '发车 / 交货截止', minWidth: 195, slotName: 'times' },
|
||||
{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false },
|
||||
{ key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
|
||||
]
|
||||
function switchTab(value: 'offers' | 'inquiries') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try {
|
||||
const q = applied.value
|
||||
if (tab.value === 'offers') {
|
||||
const query: OfferQuery = { page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
|
||||
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
|
||||
} else {
|
||||
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
|
||||
const value = await listCapacityInquiries(query); if (current === sequence) { inquiries.value = value.records; total.value = value.total }
|
||||
}
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车需求池读取失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
|
||||
function accept(value: InquiryResult) {
|
||||
const index = inquiries.value.findIndex(row => row.bookingNo === value.inquiry.bookingNo)
|
||||
if (index >= 0) inquiries.value[index] = value.inquiry
|
||||
for (const row of inquiries.value) if (row.offerNo === value.offer.offerNo) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
|
||||
selectedOffer.value = value.offer
|
||||
}
|
||||
const commands = useCapacityInquiryCommands('capacity-pool-command', accept)
|
||||
async function inquiryAction(key: string, row: CapacityInquiry) {
|
||||
if (key === 'confirm' || key === 'cancel') { await commands.run(row, key); return }
|
||||
const current = ++editorSequence; error.value = null
|
||||
if (key === 'edit') {
|
||||
try { const detail = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = detail.offer }
|
||||
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '意向编辑信息读取失败'); return }
|
||||
} else selectedOffer.value = null
|
||||
editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true
|
||||
}
|
||||
async function recheckEditor() {
|
||||
if (!editing.value) return
|
||||
const current = ++editorSequence
|
||||
try {
|
||||
const detail = await getCapacityOffer(editing.value.offerNo)
|
||||
const checked = detail.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
|
||||
if (current === editorSequence) { selectedOffer.value = detail.offer; editing.value = checked }
|
||||
} catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '最新意向核对失败') }
|
||||
}
|
||||
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车需求池">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" :aria-selected="tab === 'offers'" :class="{ 'is-active': tab === 'offers' }" @click="switchTab('offers')">可接运力</button><button role="tab" :aria-selected="tab === 'inquiries'" :class="{ 'is-active': tab === 'inquiries' }" @click="switchTab('inquiries')">我的意向</button></div>
|
||||
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||
<template #toolbar-start><h1>拼车需求池</h1></template><template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="可接运力查询" :loading="loading" @search="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
||||
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
|
||||
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
|
||||
<template #times="{ row }"><div>{{ offerTime(row.plannedDepartureAt) }}</div><div>截止 {{ offerTime(row.cutoffAt) }}</div></template>
|
||||
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
|
||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可确认承运' : '暂不可确认' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
||||
</DataTable>
|
||||
<CapacityInquiryTable v-else integrated v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||||
<template #toolbar-start><h1>{{ applied.scope === 'ALL' ? '全部意向' : '我的意向' }}</h1></template><template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
|
||||
</CapacityInquiryTable>
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="recheckEditor" />
|
||||
</ListPageLayout></AdminPage></template>
|
||||
@@ -15,6 +15,7 @@ import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import TripAssignmentPanel from '../components/TripAssignmentPanel.vue'
|
||||
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
|
||||
import TripBookingPanel from '../components/TripBookingPanel.vue'
|
||||
import CapacityOfferSettingsDialog from '../components/CapacityOfferSettingsDialog.vue'
|
||||
import TripCostPanel from '../components/TripCostPanel.vue'
|
||||
import TripHandoverPanel from '../components/TripHandoverPanel.vue'
|
||||
import { getTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, getTripLoading, updateTripPlan,
|
||||
@@ -27,6 +28,7 @@ import { tripStatusLabels, tripReviewLabels, type TripDetail, type TripLoading,
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
|
||||
const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('')
|
||||
const offerSettingsOpen = ref(false)
|
||||
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
|
||||
const history = ref<TripHistory[]>([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
|
||||
const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new Date().toISOString()), endLocation = ref('')
|
||||
@@ -181,7 +183,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布设置</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
|
||||
<div v-loading="loading"><template v-if="detail">
|
||||
@@ -215,6 +217,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
</template>
|
||||
<FormSection v-if="activeTab === 'history'" class="density-form-section" title="操作历史"><DataTable v-model:page="historyPage" :page-size="20" :data="history" :total="historyTotal" :loading="historyLoading" :columns="historyColumns" row-key="recordVersion"><template #time="{ row }">{{ time(row.occurredAt) }}</template><template #action="{ row }">{{ actionLabels[row.action] || '更新记录' }}</template></DataTable></FormSection>
|
||||
<TripPlanDialog v-model="editorOpen" :trip="detail.trip" @saved="accept" />
|
||||
<CapacityOfferSettingsDialog v-model="offerSettingsOpen" :trip="detail.trip" @saved="detail.trip.recordVersion = $event" />
|
||||
</template></div>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const overseasMasterDataPermissions = {
|
||||
capacityOfferList: 'platform:fleet:capacity-offer:list',
|
||||
capacityOfferManage: 'platform:fleet:capacity-offer:manage',
|
||||
capacityInquiryCreate: 'platform:fleet:capacity-offer:inquiry-create',
|
||||
capacityInquiryEdit: 'platform:fleet:capacity-offer:inquiry-edit',
|
||||
capacityInquiryCancel: 'platform:fleet:capacity-offer:inquiry-cancel',
|
||||
fleetQuoteList: 'platform:fleet:quote:list',
|
||||
fleetQuoteCreate: 'platform:fleet:quote:create',
|
||||
fleetQuoteEdit: 'platform:fleet:quote:edit',
|
||||
|
||||
@@ -7,6 +7,8 @@ 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/capacity-offers', overseasMasterDataPermissions.capacityOfferList],
|
||||
['overseas-master-data/capacity-offers/:offerNo', overseasMasterDataPermissions.capacityOfferList],
|
||||
['overseas-master-data/fleet-quotes', overseasMasterDataPermissions.fleetQuoteList],
|
||||
['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList],
|
||||
['overseas-master-data/trips', overseasMasterDataPermissions.tripList],
|
||||
@@ -20,6 +22,15 @@ describe('overseas master data routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('allows dispatch management to read the pool without granting full trip access to sales', () => {
|
||||
const pool = overseasMasterDataRoutes.filter(route => route.path.includes('capacity-offers'))
|
||||
expect(pool).toHaveLength(2)
|
||||
for (const route of pool) {
|
||||
expect(route.meta?.permissionAnyOf).toEqual([overseasMasterDataPermissions.capacityOfferManage])
|
||||
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.tripList)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => {
|
||||
expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false)
|
||||
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
|
||||
|
||||
@@ -2,6 +2,18 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
import { overseasMasterDataPermissions as permission } from './permissions'
|
||||
|
||||
export const overseasMasterDataRoutes = [
|
||||
{
|
||||
path: 'overseas-master-data/capacity-offers',
|
||||
name: 'overseas-master-data-capacity-offers',
|
||||
component: () => import('./pages/CapacityOffersView.vue'),
|
||||
meta: { title: '拼车需求池', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/capacity-offers/:offerNo',
|
||||
name: 'overseas-master-data-capacity-offer-detail',
|
||||
component: () => import('./pages/CapacityOfferDetailView.vue'),
|
||||
meta: { title: '拼车需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/fleet-quotes',
|
||||
name: 'overseas-master-data-fleet-quotes',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { commandCapacityInquiry } from './api/capacityOffers'
|
||||
import type { CapacityInquiry, InquiryCommand, InquiryResult } from './capacityOfferTypes'
|
||||
export function useCapacityInquiryCommands(key: string, accept: (value: InquiryResult) => void) {
|
||||
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const state = reactive<{ pending: { bookingNo: string; action: 'confirm' | 'cancel'; body: InquiryCommand } | null }>({ pending: null })
|
||||
useUnsavedWorkRegistration({ key, label: computed(() => '待核对拼车命令'), isOpen: computed(() => !!state.pending), model: state, requiredPermission: computed(() => null) })
|
||||
async function retry() {
|
||||
if (!state.pending || busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { const value = await commandCapacityInquiry(state.pending.bookingNo, state.pending.action, state.pending.body); state.pending = null; accept(value); ElMessage.success('意向已更新') }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '意向操作未完成'); if (cause instanceof ApiResponseError) state.pending = null }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function run(row: CapacityInquiry, action: 'confirm' | 'cancel') {
|
||||
if (busy.value) return
|
||||
const manage = access.can('platform:fleet:capacity-offer:manage')
|
||||
if (action === 'confirm' ? !manage || !row.actions.canConfirm : !row.actions.canCancel || !(manage || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-cancel'))) return
|
||||
if (state.pending) { ElMessage.warning('请先重试上一条结果未知的操作'); return }
|
||||
try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量占位并创建本土订单?` : `取消 ${row.bookingNo}?未登记来货的已确认承运将一并取消商业订单并释放预留。`, action === 'confirm' ? '确认拼车承运' : '取消拼车意向', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '返回' }) } catch { return }
|
||||
state.pending = { bookingNo: row.bookingNo, action, body: { tripRecordVersion: row.tripRecordVersion, offerRecordVersion: row.offerRecordVersion, recordVersion: row.recordVersion, idempotencyKey: crypto.randomUUID() } }
|
||||
await retry()
|
||||
}
|
||||
return { busy, error, state, run, retry }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { computed, getCurrentInstance, reactive, ref, type Ref } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
|
||||
|
||||
/** Veto native closing and supplement cached route switches; retain the shared registry for other exits. */
|
||||
export function useFleetDraftGuard(key: string, open: Ref<boolean>, model: object, busy: Ref<boolean>, uncertain: Ref<boolean>) {
|
||||
const baseline = ref(''), checking = ref(false)
|
||||
const snapshot = computed(() => JSON.stringify(model))
|
||||
const registration = useUnsavedWorkRegistration({ key: `${key}:${getCurrentInstance()?.uid}`, label: computed(() => '拼车表单'), isOpen: computed(() => open.value || uncertain.value), model: reactive({ form: model, uncertain }), requiredPermission: computed(() => null) })
|
||||
function capture() { baseline.value = snapshot.value; if (!uncertain.value) registration.captureBaseline() }
|
||||
async function mayClose(leaving = false) {
|
||||
if (busy.value || checking.value) return false
|
||||
if (snapshot.value !== baseline.value || uncertain.value) {
|
||||
checking.value = true
|
||||
try { await ElMessageBox.confirm(
|
||||
uncertain.value ? `上次提交结果尚未确定,原请求将保留。${leaving ? '离开' : '关闭'}后可重新打开并核对或重试,是否继续?` : leaving ? '表单有未保存内容,离开后将关闭表单并放弃本次修改。是否离开?' : '表单有未保存内容,确认放弃本次修改?',
|
||||
leaving ? '离开页面' : '关闭表单',
|
||||
{ customClass: 'admin-density-confirm', confirmButtonText: leaving ? uncertain.value ? '保留请求并离开' : '放弃并离开' : '确认关闭', cancelButtonText: '继续填写' },
|
||||
) }
|
||||
catch { return false }
|
||||
finally { checking.value = false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
async function beforeClose(done: () => void) { if (await mayClose()) done() }
|
||||
if (getCurrentInstance()?.proxy?.$route) {
|
||||
onBeforeRouteLeave(async (to, from) => {
|
||||
// The global guard protects destructive navigation; it intentionally skips cached workspace switches.
|
||||
if (!isWorkspaceRoute(to) || workspaceRouteId(to) === workspaceRouteId(from) || !open.value && !uncertain.value) return true
|
||||
if (!await mayClose(true)) return false
|
||||
open.value = false
|
||||
return true
|
||||
})
|
||||
}
|
||||
return { capture, beforeClose, dirty: computed(() => snapshot.value !== baseline.value) }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
@@ -13,6 +13,7 @@ const ElDialogStub = defineComponent({
|
||||
closeOnClickModal: Boolean,
|
||||
closeOnPressEscape: Boolean,
|
||||
fullscreen: Boolean,
|
||||
beforeClose: Function,
|
||||
},
|
||||
template: '<section data-testid="dialog"><h2 v-if="!$slots.header" data-testid="native-title">{{ title }}</h2><slot name="header" /><slot /><slot name="footer" /></section>',
|
||||
})
|
||||
@@ -62,6 +63,20 @@ describe('AppDialog', () => {
|
||||
expect(cancelButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('applies the same opt-in beforeClose hook to native close and the default cancel action', async () => {
|
||||
let allowClose: (() => void) | undefined
|
||||
const beforeClose = vi.fn((done: () => void) => { allowClose = done })
|
||||
const wrapper = mountDialog({ beforeClose })
|
||||
expect(wrapper.getComponent(ElDialogStub).props('beforeClose')).toBe(beforeClose)
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
expect(beforeClose).toHaveBeenCalledOnce()
|
||||
expect(wrapper.emitted('cancel')).toBeUndefined()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
allowClose!()
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('can render a content-only dialog without footer actions', () => {
|
||||
const wrapper = mountDialog({ showFooter: false })
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
|
||||
@@ -25,6 +25,7 @@ const props = withDefaults(defineProps<{
|
||||
lockWhileSubmitting?: boolean
|
||||
fullscreenable?: boolean
|
||||
defaultFullscreen?: boolean
|
||||
beforeClose?: (done: () => void) => void | Promise<void>
|
||||
}>(), {
|
||||
width: 'min(560px, calc(100vw - 28px))',
|
||||
loading: false,
|
||||
@@ -63,8 +64,12 @@ function toggleFullscreen() {
|
||||
|
||||
function closeDialog() {
|
||||
if (submittingLocked.value) return
|
||||
open.value = false
|
||||
emit('cancel')
|
||||
const done = () => {
|
||||
open.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
if (props.beforeClose) void props.beforeClose(done)
|
||||
else done()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -79,6 +84,7 @@ function closeDialog() {
|
||||
:close-on-click-modal="closeOnClickModal && !submittingLocked"
|
||||
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
|
||||
:destroy-on-close="destroyOnClose"
|
||||
:before-close="beforeClose"
|
||||
:fullscreen="fullscreenable && fullscreen"
|
||||
:class="{ 'has-native-fullscreen-control': fullscreenable && !$slots.header }"
|
||||
>
|
||||
|
||||
@@ -64,6 +64,11 @@ const generalCatalogConsumers = [
|
||||
'overseas-master-data/pages/VehicleDetailView.vue',
|
||||
'overseas-master-data/pages/DriversView.vue',
|
||||
'overseas-master-data/pages/TripsView.vue',
|
||||
'overseas-master-data/pages/CapacityOffersView.vue',
|
||||
'overseas-master-data/pages/CapacityOfferDetailView.vue',
|
||||
'overseas-master-data/components/CapacityInquiryDialog.vue',
|
||||
'overseas-master-data/components/CapacityInquiryTable.vue',
|
||||
'overseas-master-data/components/CapacityOfferSettingsDialog.vue',
|
||||
'overseas-master-data/pages/TripOperationsView.vue',
|
||||
'overseas-master-data/pages/TripDetailView.vue',
|
||||
'overseas-master-data/components/TripPlanDialog.vue',
|
||||
@@ -85,6 +90,7 @@ const notificationConsumers = [
|
||||
]
|
||||
const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue']
|
||||
const consumers = [
|
||||
'overseas-business/components/FleetArrangementSummary.vue',
|
||||
...notificationConsumers,
|
||||
...systemConsumers,
|
||||
...generalCatalogConsumers,
|
||||
|
||||
Reference in New Issue
Block a user