优化
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# 海外通用资料与车辆管理
|
||||
|
||||
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
|
||||
|
||||
- 内部拼车需求池:`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,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 海外通用资料原型核对矩阵
|
||||
|
||||
## 运输路线地图
|
||||
|
||||
本轮最新要求:使用已有起点、目的地展示规划路线,后续另接GPS。唯一组件为`components/TransportRouteDialog.vue`,经`index.ts`公开导出;无固定样例、无定位采集、不产生业务记录。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 当前承载与结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 排车列表 | 查看路线 | 用户提供`C:/Users/BY-OY/Desktop/新版.html:18627`,轨迹列在车辆状态前 | 装车后车辆信息分区增加路线地图列;起运仓按排车响应warehouseId查询详情;目的仓由服务端route字段拆分后精确查FBA仓码再读详情,多目的仓显式选择 |
|
||||
| 本土散板列表 | 查看路线 | 同原型27221、27248行 | 在状态前放路线地图列,使用本行origin/destination,FBA仓码先解析为仓库完整地址;当前没有装车/返程分栏,不新增状态、不提供伪造返程轨迹 |
|
||||
| 本土卡车新增/编辑 | 查看路线 | 同原型7373、7474行,车辆与派车分区 | 表单末尾车辆与派车区域打开,起终点分别组合地点与详细地址,FBA仓码改查主档地址,不要求先保存订单、不补造装箱日期字段 |
|
||||
|
||||
统一1100px居中、58vh地图。`routeLocations.ts`区分明确地址与仓库引用,复用Warehouse的列表/详情接口(`platform:warehouse:list`),不变更服务端权限或API。仓码查询限定FBA并做精确匹配;仓库ID直读详情;只采用唯一运营地址(无运营地址时也必须唯一),必须有街道、城市、国家,再拼州、邮编等字段。缺失、不唯一、码与详情不符及接口错误均不发地图请求,不回退仓码或名称查询地图。弹框明确展示解析后的地址及错误诊断,重试重新查询。
|
||||
|
||||
仅显式打开/选择目的仓时解析地址,同次打开缓存已查询地点;关闭/换记录丢弃缓存并忽略旧响应,加载时移除旧地图。内嵌与外部打开使用同一完整地址,列表不逐行请求主档。非仓码的用户填写地点文本保持订单语义,不新增地理编码或GPS服务。
|
||||
|
||||
原型中的轨迹名称按最新用户要求改为路线,撤销车辆主档顶部临时演示入口。新增/编辑/审核/上传/删除行为不变。1920×1080、100%缩放视觉待人工截图确认;真实地图联网效果待人工确认。
|
||||
|
||||
## 内部拼车需求池首版
|
||||
|
||||
依据:用户授权按现有车次样式整合;唯一范围为 [内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7、9节,没有另引入外部原型。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TransportRouteDialog from './TransportRouteDialog.vue'
|
||||
import { resolveRouteLocation, routeLocation, RouteAddressError, type RouteLocation } from '../routeLocations'
|
||||
vi.mock('../routeLocations', async importOriginal => ({ ...await importOriginal<object>(), resolveRouteLocation: vi.fn() }))
|
||||
const global = { stubs: {
|
||||
AppDialog: { template: '<div><slot /></div>' }, ElButton: { template: '<button><slot /></button>' }, ElLink: { template: '<a><slot /></a>' },
|
||||
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
ElSelect: { props: ['modelValue'], emits: ['update:modelValue'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', Number($event.target.value))"><slot /></select>' },
|
||||
ElOption: { props: ['label', 'value'], template: '<option :value="value">{{ label }}</option>' },
|
||||
} }
|
||||
const endpoints = { origin: { kind: 'warehouse', label: '洛杉矶仓', warehouseId: '123' } as RouteLocation, destinations: [routeLocation('ABE3'), routeLocation('ONT8')] }
|
||||
const addresses: Record<string, string> = { '洛杉矶仓': '100 Example Street, Los Angeles, CA, US', ABE3: '200 Example Road, Example City, PA, 18000, US', ONT8: '300 Example Avenue, Ontario, CA, US' }
|
||||
beforeEach(() => { vi.mocked(resolveRouteLocation).mockReset().mockImplementation(async location => location.kind === 'address' ? location.address : addresses[location.label]!) })
|
||||
describe('transport route address resolution', () => {
|
||||
it('does not query until opened and sends full addresses to both map URLs', async () => {
|
||||
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: false }, global })
|
||||
expect(resolveRouteLocation).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const embedded = new URL(wrapper.get('iframe').attributes('src')!)
|
||||
const external = new URL(wrapper.get('a').attributes('href')!)
|
||||
expect(embedded.searchParams.get('saddr')).toBe(addresses['洛杉矶仓'])
|
||||
expect(embedded.searchParams.get('daddr')).toBe(addresses.ABE3)
|
||||
expect(external.searchParams.get('destination')).toBe(addresses.ABE3)
|
||||
expect(wrapper.text()).toContain('目的地地址:' + addresses.ABE3)
|
||||
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
|
||||
const iframe = wrapper.get('iframe').element
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.get('iframe').element).not.toBe(iframe)
|
||||
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
expect(wrapper.find('iframe').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('resolves only the selected warehouse and reuses the origin for another destination', async () => {
|
||||
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global }); await flushPromises()
|
||||
await wrapper.get('select').setValue('1'); await flushPromises()
|
||||
expect(new URL(wrapper.get('iframe').attributes('src')!).searchParams.get('daddr')).toBe(addresses.ONT8)
|
||||
expect(resolveRouteLocation).toHaveBeenCalledTimes(3)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('never leaves the previous map visible while resolving a new destination, and ignores late results', async () => {
|
||||
let finish!: (value: string) => void
|
||||
vi.mocked(resolveRouteLocation).mockImplementation(location => location.label === 'ABE3' ? new Promise(resolve => { finish = resolve }) : Promise.resolve(addresses[location.label]!))
|
||||
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global })
|
||||
expect(wrapper.find('iframe').exists()).toBe(false)
|
||||
await wrapper.get('select').setValue('1'); await flushPromises()
|
||||
finish('stale address'); await flushPromises()
|
||||
expect(new URL(wrapper.get('iframe').attributes('src')!).searchParams.get('daddr')).toBe(addresses.ONT8)
|
||||
await wrapper.setProps({ modelValue: false }); await flushPromises()
|
||||
expect(wrapper.find('iframe').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows missing address errors instead of falling back to a warehouse code', async () => {
|
||||
vi.mocked(resolveRouteLocation).mockRejectedValue(new RouteAddressError('ABE3缺少完整仓库地址'))
|
||||
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('ABE3缺少完整仓库地址')
|
||||
expect(wrapper.find('iframe').exists()).toBe(false); expect(wrapper.find('a').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not request missing endpoints', async () => {
|
||||
const wrapper = mount(TransportRouteDialog, { props: { origin: routeLocation(''), destinations: [], modelValue: true }, global }); await flushPromises()
|
||||
expect(resolveRouteLocation).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少起点或目的地')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { resolveRouteLocation, RouteAddressError, type RouteLocation } from '../routeLocations'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ origin: RouteLocation; destinations: RouteLocation[] }>()
|
||||
const mapRevision = ref(0)
|
||||
const destinationIndex = ref(0)
|
||||
const selected = computed(() => props.destinations[destinationIndex.value])
|
||||
const origin = ref(''), destination = ref(''), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let generation = 0
|
||||
let cache = new Map<string, Promise<string>>()
|
||||
function resolve(location: RouteLocation) {
|
||||
const key = JSON.stringify(location)
|
||||
if (!cache.has(key)) cache.set(key, resolveRouteLocation(location))
|
||||
return cache.get(key)!
|
||||
}
|
||||
async function load() {
|
||||
const current = ++generation
|
||||
origin.value = ''; destination.value = ''; error.value = null; loading.value = false
|
||||
if (!open.value) return
|
||||
const valid = (location?: RouteLocation) => location && !['', '—', '-', '仓库名称待补充'].includes(location.label.trim())
|
||||
if (!valid(props.origin) || !valid(selected.value)) {
|
||||
error.value = { message: '缺少起点或目的地,请先补充地点或详细地址' }; return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const [start, end] = await Promise.all([resolve(props.origin), resolve(selected.value!)])
|
||||
if (current === generation && open.value) { origin.value = start; destination.value = end; mapRevision.value++ }
|
||||
} catch (cause) {
|
||||
if (current === generation) error.value = cause instanceof RouteAddressError ? { message: cause.message } : getApiErrorDetails(cause, '仓库地址查询失败,请确认具有仓库查看权限后重试')
|
||||
} finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function retry() { cache = new Map(); void load() }
|
||||
watch([open, () => props.origin, () => props.destinations], () => {
|
||||
destinationIndex.value = 0; cache = new Map(); void load()
|
||||
}, { immediate: true })
|
||||
watch(destinationIndex, load)
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
const ready = computed(() => !loading.value && !error.value && Boolean(origin.value && destination.value))
|
||||
const mapUrl = computed(() => `https://maps.google.com/maps?${new URLSearchParams({ saddr: origin.value, daddr: destination.value, dirflg: 'd', output: 'embed' })}`)
|
||||
const externalUrl = computed(() => `https://www.google.com/maps/dir/?${new URLSearchParams({ api: '1', origin: origin.value, destination: destination.value, travelmode: 'driving' })}`)
|
||||
const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.length > 2048)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="查看路线"
|
||||
width="min(1100px, 96vw)" align-center :show-confirm="false" cancel-text="关闭">
|
||||
<section v-if="open" class="route-map" aria-label="起点至目的地路线">
|
||||
<strong>{{ props.origin.label || '起点未填写' }} → {{ selected?.label || '目的地未填写' }}</strong>
|
||||
<el-select v-if="props.destinations.length > 1" v-model="destinationIndex" aria-label="选择目的仓">
|
||||
<el-option v-for="(item, index) in props.destinations" :key="index" :label="item.label" :value="index" />
|
||||
</el-select>
|
||||
<p class="route-note">根据起点与目的地地址规划驾车路线,尚未接入 GPS,不显示车辆实时位置或实际行驶轨迹。</p>
|
||||
<p v-if="loading" role="status">正在查询仓库详细地址…</p>
|
||||
<LoadErrorState v-else-if="error" title="无法加载路线" :message="error.message" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-else-if="tooLong" role="status">地点信息过长,请核对起点和目的地地址后重试。</p>
|
||||
<template v-else-if="ready"><div class="route-addresses"><div>起点地址:{{ origin }}</div><div>目的地地址:{{ destination }}</div></div>
|
||||
<iframe :key="mapRevision" :src="mapUrl" :title="`${props.origin.label}至${selected?.label}路线地图`"
|
||||
referrerpolicy="no-referrer" />
|
||||
</template>
|
||||
<div v-if="ready && !tooLong" class="route-help">
|
||||
<span>地图需联网加载。如未显示,可重新加载或在新窗口打开。</span>
|
||||
<el-button @click="mapRevision++">重新加载地图</el-button>
|
||||
<el-link :href="externalUrl" target="_blank" rel="noopener noreferrer" type="primary">在新窗口打开地图</el-link>
|
||||
</div>
|
||||
</section>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.route-map { display: grid; gap: var(--admin-space-3); }
|
||||
.route-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.route-map iframe { width: 100%; height: 58vh; min-height: 320px; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
|
||||
.route-help { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); color: var(--cc-muted); }
|
||||
</style>
|
||||
@@ -1,3 +1,5 @@
|
||||
export { overseasMasterDataRoutes } from './routes'
|
||||
export { overseasMasterDataPermissions } from './permissions'
|
||||
export type * from './types'
|
||||
export { default as TransportRouteDialog } from './components/TransportRouteDialog.vue'
|
||||
export { routeLocation, type RouteLocation } from './routeLocations'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getPlatformWarehouse, listAllPlatformWarehouses, type PlatformWarehouseDetailResource } from '@/modules/warehouses'
|
||||
import { resolveRouteLocation, routeLocation } from './routeLocations'
|
||||
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn(), listAllPlatformWarehouses: vi.fn() }))
|
||||
const detail = {
|
||||
warehouse: { id: '900', warehouseCode: 'ABE3', warehouseName: 'ABE3', warehouseType: 'FBA' },
|
||||
addresses: [{ addressType: 'OPERATING', addressLine1: '200 Example Road', addressLine2: null, district: null, city: 'Example City', stateProvince: 'PA', postalCode: '18000', countryCode: 'US' }], capabilities: [],
|
||||
} as unknown as PlatformWarehouseDetailResource
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([detail.warehouse])
|
||||
vi.mocked(getPlatformWarehouse).mockReset().mockResolvedValue(detail)
|
||||
})
|
||||
describe('route warehouse address resolver', () => {
|
||||
it('looks up ABE3 by exact FBA code then reads its detail, never sending the code as an address', async () => {
|
||||
expect(await resolveRouteLocation(routeLocation('ABE3'))).toBe('200 Example Road, Example City, PA, 18000, US')
|
||||
expect(listAllPlatformWarehouses).toHaveBeenCalledExactlyOnceWith({ keyword: 'ABE3', warehouseType: 'FBA' })
|
||||
expect(getPlatformWarehouse).toHaveBeenCalledExactlyOnceWith('900')
|
||||
})
|
||||
it('uses the origin warehouse ID instead of searching its name', async () => {
|
||||
await resolveRouteLocation({ kind: 'warehouse', label: '起运仓', warehouseId: '900' })
|
||||
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects partial code matches and duplicates', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ ...detail.warehouse, warehouseCode: 'ABE30' }])
|
||||
await expect(resolveRouteLocation(routeLocation('ABE3'))).rejects.toThrow('未找到')
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([detail.warehouse, detail.warehouse])
|
||||
await expect(resolveRouteLocation(routeLocation('ABE3'))).rejects.toThrow('多个仓库')
|
||||
expect(getPlatformWarehouse).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects missing street addresses, multiple operating addresses and detail/code mismatch', async () => {
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValue({ ...detail, addresses: [{ ...detail.addresses[0]!, addressLine1: '' }] })
|
||||
await expect(resolveRouteLocation(routeLocation('ABE3'))).rejects.toThrow('完整仓库地址')
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValue({ ...detail, addresses: [detail.addresses[0]!, detail.addresses[0]!] })
|
||||
await expect(resolveRouteLocation(routeLocation('ABE3'))).rejects.toThrow('不唯一')
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValue({ ...detail, warehouse: { ...detail.warehouse, warehouseCode: 'ONT8' } })
|
||||
await expect(resolveRouteLocation(routeLocation('ABE3'))).rejects.toThrow('不一致')
|
||||
})
|
||||
it('preserves explicit order addresses without a directory request', async () => {
|
||||
expect(await resolveRouteLocation(routeLocation('Ontario', '100 Example Street'))).toBe('Ontario 100 Example Street')
|
||||
expect(getPlatformWarehouse).not.toHaveBeenCalled(); expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getPlatformWarehouse, listAllPlatformWarehouses, type PlatformWarehouseAddressResource } from '@/modules/warehouses'
|
||||
|
||||
export type RouteLocation =
|
||||
| { kind: 'address'; label: string; address: string }
|
||||
| { kind: 'warehouse'; label: string; warehouseId?: string; warehouseCode?: string }
|
||||
|
||||
export class RouteAddressError extends Error {}
|
||||
|
||||
export function routeLocation(place: string, address?: string | null): RouteLocation {
|
||||
const label = place.trim()
|
||||
// FBA codes in free-text order fields also require authoritative address resolution.
|
||||
if (/^[A-Z]{3}\d{1,2}$/i.test(label)) return { kind: 'warehouse', label, warehouseCode: label.toUpperCase() }
|
||||
return { kind: 'address', label, address: [label, address?.trim()].filter(Boolean).join(' ') }
|
||||
}
|
||||
|
||||
function formatAddress(address: PlatformWarehouseAddressResource | undefined, label: string) {
|
||||
if (!address?.addressLine1?.trim() || !address.city?.trim() || !address.countryCode?.trim()) {
|
||||
throw new RouteAddressError(`${label}缺少完整仓库地址,请在仓库资料中补充街道、城市和国家`)
|
||||
}
|
||||
return [address.addressLine1, address.addressLine2, address.district, address.city, address.stateProvince, address.postalCode, address.countryCode]
|
||||
.map(value => value?.trim()).filter(Boolean).join(', ')
|
||||
}
|
||||
|
||||
export async function resolveRouteLocation(location: RouteLocation): Promise<string> {
|
||||
if (location.kind === 'address') return location.address.trim()
|
||||
let warehouseId = location.warehouseId
|
||||
if (!warehouseId) {
|
||||
const keyword = location.warehouseCode?.trim() || location.label.trim()
|
||||
if (!keyword || ['—', '-', '仓库名称待补充'].includes(keyword)) throw new RouteAddressError('缺少仓库标识,请核对仓库资料')
|
||||
const warehouses = await listAllPlatformWarehouses({ keyword, ...(location.warehouseCode ? { warehouseType: 'FBA' as const } : {}) })
|
||||
const matches = warehouses.filter(warehouse => location.warehouseCode
|
||||
? warehouse.warehouseCode.toUpperCase() === keyword.toUpperCase()
|
||||
: warehouse.warehouseName === keyword)
|
||||
if (matches.length !== 1) throw new RouteAddressError(matches.length ? `${location.label}匹配到多个仓库,请核对仓库资料` : `未找到${location.label}的仓库资料`)
|
||||
warehouseId = String(matches[0]!.id)
|
||||
}
|
||||
const detail = await getPlatformWarehouse(warehouseId)
|
||||
if (location.warehouseCode && (detail.warehouse.warehouseType !== 'FBA' || detail.warehouse.warehouseCode.toUpperCase() !== location.warehouseCode.toUpperCase())) {
|
||||
throw new RouteAddressError(`${location.label}与查询到的仓库资料不一致,请重新查询`)
|
||||
}
|
||||
const operating = detail.addresses.filter(address => address.addressType === 'OPERATING')
|
||||
const addresses = operating.length ? operating : detail.addresses
|
||||
if (addresses.length !== 1) throw new RouteAddressError(`${location.label}的运营地址不唯一或尚未维护,请核对仓库资料`)
|
||||
return formatAddress(addresses[0], location.label)
|
||||
}
|
||||
Reference in New Issue
Block a user