优化
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# 海外业务原型核对矩阵
|
||||
|
||||
本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。
|
||||
|
||||
## 池来源本土订单承运安排
|
||||
|
||||
依据:[内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7节及本轮用户授权。
|
||||
|
||||
@@ -67,6 +67,32 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
|
||||
|
||||
it.each(['查看路线'])('opens the shared route map from looseboard %s without requerying orders', async label => {
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' })
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(false)
|
||||
const requests = vi.mocked(listLocalLooseboardOrders).mock.calls.length
|
||||
await wrapper.findAll('button').find(button => button.text() === label)!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
|
||||
expect(document.body.textContent).toContain('LA → ONT')
|
||||
expect(listLocalLooseboardOrders).toHaveBeenCalledTimes(requests)
|
||||
})
|
||||
|
||||
it.each([false, true])('opens truck route map on create/edit without saving (%s)', async editing => {
|
||||
if (editing) { routing.route.params = { orderId: truck.id }; routing.route.query = { customerId: truck.customerId } }
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看路线')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
|
||||
expect(createUsTruckOrder).not.toHaveBeenCalled()
|
||||
expect(updateUsTruckOrder).not.toHaveBeenCalled()
|
||||
const dialog = wrapper.findComponent({ name: 'TransportRouteDialog' })
|
||||
expect(dialog.props('origin')).toEqual({ kind: 'address', label: editing ? 'Long Beach' : '', address: editing ? 'Long Beach Pier A' : '' })
|
||||
expect(dialog.props('destinations')).toEqual([{ kind: 'address', label: editing ? 'Ontario' : '', address: editing ? 'Ontario Warehouse B' : '' }])
|
||||
})
|
||||
|
||||
it('opens the associated commercial order using a server-side order number filter', async () => {
|
||||
routing.route.query = { orderNo: 'LB-LINKED' }
|
||||
const wrapper = mount(TransportOrdersView, { props: { domain: 'looseboard' }, global: { plugins: [ElementPlus] } })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { TransportRouteDialog, routeLocation } from '@/modules/overseas-master-data'
|
||||
import '../overseasBusinessDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -22,6 +23,7 @@ import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const routeOpen = ref(false)
|
||||
const route = useRoute(); const router = useRouter(); const formRef = ref<FormInstance>()
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const routeCustomerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
@@ -124,8 +126,8 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
|
||||
<FormSection class="density-form-section" title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</el-form><FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
|
||||
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Plus } from '@lucide/vue'
|
||||
@@ -19,6 +20,13 @@ import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const routeOpen = ref(false)
|
||||
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
function openRoute(row: LocalTransportOrder | LocalLooseboardOrder) {
|
||||
routeOrigin.value = routeLocation(row.origin)
|
||||
routeDestinations.value = [routeLocation(row.destination)]
|
||||
routeOpen.value = true
|
||||
}
|
||||
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
|
||||
const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
|
||||
@@ -59,7 +67,13 @@ const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const columns = computed(() => isLooseboard.value ? looseboardColumns : truckColumns)
|
||||
const columns = computed(() => {
|
||||
if (!isLooseboard.value) return truckColumns
|
||||
const visible = [...looseboardColumns]
|
||||
visible.splice(visible.findIndex(column => column.key === 'status'), 0,
|
||||
{ key: 'routeMap', label: '路线地图', width: 120, slotName: 'routeMap' })
|
||||
return visible
|
||||
})
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
async function load() {
|
||||
rows.value = []; total.value = 0; loadError.value = null; loading.value = true
|
||||
@@ -93,8 +107,9 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
|
||||
<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-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>
|
||||
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
|
||||
</DataTable></section>
|
||||
</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>
|
||||
</ListPageLayout><AppDialog v-model="arrangementOpen" class="admin-density-dialog" title="承运安排" width="1080px" :show-confirm="false" cancel-text="关闭"><FleetArrangementSummary v-if="arrangement" :arrangement="arrangement" /></AppDialog><TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" /></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>.table-panel { overflow:hidden; }</style>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# Admin 海外仓模块指引
|
||||
|
||||
- 排车装车后车辆信息分区的路线地图,通过`../overseas-master-data/index.ts`公开组件按起运仓ID和目的仓精确仓码查询完整地址后展示规划路线;多目的仓独立选择,不推配送顺序。原型及字段边界见`../overseas-master-data/PROTOTYPE_MATRIX.md`“运输路线地图”。
|
||||
|
||||
## 样式入口
|
||||
|
||||
- 导航“海外仓”的 10 个菜单及关联详情/弹框采用公共密度样式,逐操作范围见 [海外仓样式核对记录](../../../../../docs/Admin海外仓样式核对记录.md)。不要扩展到客户服务、海外业务或未注册产品/箱号页面。
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
| 尾程详情 | 查看关联单据 | 本轮用户截图2及已确认方案 | 保留独立详情、基本信息页签;增加紧凑关联单据区 | 待人工截图确认 |
|
||||
| 本土散板/卡车列表 | 来源商业单跳转 | 本轮已确认方案、现有承运安排入口 | 有列表权限才跳转既有列表,携带商业单号筛选;池来源保留1080px承运安排弹框 | 自动检查与人工确认分别报告 |
|
||||
|
||||
| 页面 | 操作 | 依据 | 当前实现与结论 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表 | 阶段切换与审核页签 | 本轮用户截图 Image #1 与“只有装车前”显示审核的明确要求 | 原先各阶段均显示审核页签;调整为仅装车前显示调度审核、理货员审核,其他阶段仅车辆信息,离开装车前自动回到车辆信息;沿用现有页签布局和车次详情跳转 | 阶段切换与审核跳转自动回归;1920×1080、100% 缩放待人工截图确认 |
|
||||
|
||||
排车装车后车辆信息分区增加路线地图列,使用公开`TransportRouteDialog`。原型、字段来源和多目的仓选择见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。其他阶段不新增路线列,不逐行请求地图。
|
||||
|
||||
### F33 已有库存转运履约接入(2026-10-04)
|
||||
|
||||
| 页面 | 操作 | 依据 | 承载方式与本轮结论 | 验收 |
|
||||
|
||||
@@ -9,6 +9,10 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
|
||||
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
|
||||
import DispatchesView from './DispatchesView.vue'
|
||||
vi.mock('@/modules/warehouses', () => ({
|
||||
listAllPlatformWarehouses: vi.fn(async (query: { keyword: string }) => [{ id: query.keyword, warehouseCode: query.keyword, warehouseType: 'FBA' }]),
|
||||
getPlatformWarehouse: vi.fn(async (id: string) => ({ warehouse: { warehouseCode: id, warehouseType: id === '321' ? 'OVERSEAS' : 'FBA' }, addresses: [{ addressType: 'OPERATING', addressLine1: '100 Example Street', city: 'City', stateProvince: 'CA', countryCode: 'US' }], capabilities: [] })),
|
||||
}))
|
||||
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
@@ -44,8 +48,8 @@ describe('DispatchesView', () => {
|
||||
vi.mocked(listDispatches).mockReset().mockResolvedValue({
|
||||
records: [{
|
||||
id: '190000000000000099', dispatchNo: 'PC-1', vehiclePlate: 'CA-100', driverName: '张师傅',
|
||||
driverPhone: '555-0100', carrierName: 'Carrier A', orderNos: 'LM-1,LM-2', route: 'LAX -> ONT8',
|
||||
overseasWarehouseName: '洛杉矶仓', departAt: '2026-08-20T09:00:00', fbaAppointmentNo: 'FBA-1',
|
||||
driverPhone: '555-0100', carrierName: 'Carrier A', orderNos: 'LM-1,LM-2', route: 'ONT8,LAX9',
|
||||
overseasWarehouseId: '321', overseasWarehouseName: '洛杉矶仓', departAt: '2026-08-20T09:00:00', fbaAppointmentNo: 'FBA-1',
|
||||
fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'BOOKED', estimatedArrivalAt: '2026-08-20T18:00:00',
|
||||
status: 'PENDING_DEPARTURE', palletCount: 12, packageCount: 80, createdAt: '2026-08-19T16:00:00',
|
||||
}],
|
||||
@@ -78,6 +82,25 @@ describe('DispatchesView', () => {
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'AFTER_LOADING', page: 1 }))
|
||||
})
|
||||
|
||||
it('shows the shared route map only in the after-loading vehicle section without another query', async () => {
|
||||
const wrapper = mount(DispatchesView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('查看路线')
|
||||
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[3]!.trigger('click')
|
||||
await flushPromises()
|
||||
const requests = vi.mocked(listDispatches).mock.calls.length
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看路线')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('origin')).toEqual({ kind: 'warehouse', label: '洛杉矶仓', warehouseId: '321' })
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('destinations')).toEqual(['ONT8', 'LAX9'].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code })))
|
||||
expect(listDispatches).toHaveBeenCalledTimes(requests)
|
||||
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[aria-label="排车业务分区"]').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '查看路线')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows review facts and opens the matching trip section with read permission', async () => {
|
||||
const result = await listDispatches({ page: 1, size: 10 })
|
||||
result.records[0]!.workflow = { tripNo: 'TR-A', dispatchReview: { status: 'REJECTED', reason: '请核对货量', reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
|
||||
@@ -86,6 +109,9 @@ describe('DispatchesView', () => {
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const sections = wrapper.get('[aria-label="排车业务分区"]')
|
||||
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息'])
|
||||
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
|
||||
expect(wrapper.findAll('[role="tablist"]')).toHaveLength(2)
|
||||
expect(wrapper.text()).not.toContain('请核对货量')
|
||||
@@ -103,7 +129,28 @@ describe('DispatchesView', () => {
|
||||
await links[0]!.trigger('click')
|
||||
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'loading' } })
|
||||
expect(wrapper.text()).not.toContain('前往审核')
|
||||
expect(listDispatches).toHaveBeenCalledTimes(1)
|
||||
expect(listDispatches).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([0, 2, 3, 4].flatMap(stageIndex => [1, 2].map(sectionIndex => [stageIndex, sectionIndex])))('leaving a review section for stage %s resets section %s to vehicle information', async (stageIndex, sectionIndex) => {
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const stages = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
|
||||
const sections = wrapper.get('[aria-label="排车业务分区"]')
|
||||
await stages[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
await sections.findAll('button')[sectionIndex]!.trigger('click')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false)
|
||||
await stages[stageIndex]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息'])
|
||||
expect(sections.get('button').attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(true)
|
||||
expect(wrapper.findAll('button').some(button => ['前往审核', '查看审核'].includes(button.text()))).toBe(false)
|
||||
await stages[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
|
||||
expect(sections.get('button').attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -24,6 +25,14 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { dispatchVehicleSizeOptions } from '../dispatchVehicleSpecs'
|
||||
|
||||
const router = useRouter()
|
||||
const routeOpen = ref(false)
|
||||
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
function openRoute(row: DispatchListItem) {
|
||||
routeOrigin.value = { kind: 'warehouse', label: row.overseasWarehouse, warehouseId: dispatchSnapshots.get(row.dispatchNo)?.overseasWarehouseId || undefined }
|
||||
// The service's route field is a comma-separated set of destination warehouse codes, not a stop sequence.
|
||||
routeDestinations.value = [...new Set(row.route.split(',').map(code => code.trim()).filter(Boolean))].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code }))
|
||||
routeOpen.value = true
|
||||
}
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<DispatchListItem[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -43,11 +52,13 @@ const sections = [
|
||||
{ value: 'dispatchReview', label: '调度审核' },
|
||||
{ value: 'loadingReview', label: '理货员审核' },
|
||||
] as const
|
||||
const visibleSections = computed(() => stage.value === 'BEFORE_LOADING' ? sections : sections.filter(item => item.value === 'vehicle'))
|
||||
const reviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' }
|
||||
let loadSequence = 0
|
||||
function switchStage(value: typeof stage.value) {
|
||||
if (stage.value === value) return
|
||||
stage.value = value
|
||||
if (value !== 'BEFORE_LOADING') section.value = 'vehicle'
|
||||
filters.value.status = ''
|
||||
page.value = 1
|
||||
void load()
|
||||
@@ -257,7 +268,12 @@ const columns: DataTableColumn[] = [
|
||||
]
|
||||
|
||||
const visibleColumns = computed(() => {
|
||||
if (section.value === 'vehicle') return columns.filter(column => !['dispatchReview', 'loadingReview'].includes(String(column.key)))
|
||||
if (section.value === 'vehicle') {
|
||||
const visible = columns.filter(column => !['dispatchReview', 'loadingReview'].includes(String(column.key)))
|
||||
if (stage.value === 'AFTER_LOADING') visible.splice(visible.findIndex(column => column.key === 'status'), 0,
|
||||
{ key: 'routeMap', label: '路线地图', width: 120, slotName: 'routeMap' })
|
||||
return visible
|
||||
}
|
||||
const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status']
|
||||
return keys.map(key => columns.find(column => column.key === key)!)
|
||||
})
|
||||
@@ -644,7 +660,7 @@ onMounted(load)
|
||||
<button v-for="item in stages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }}</button>
|
||||
</div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车业务分区">
|
||||
<button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="section = item.value">{{ item.label }}</button>
|
||||
<button v-for="item in visibleSections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="section = item.value">{{ item.label }}</button>
|
||||
</div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></div></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
@@ -662,8 +678,10 @@ onMounted(load)
|
||||
<span v-else>未关联车次</span>
|
||||
</template>
|
||||
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
|
||||
<template #routeMap="{ row }"><el-button v-if="row.dispatchNo" link type="primary" @click="openRoute(row as DispatchListItem)">查看路线</el-button></template>
|
||||
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
||||
</DataTable>
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" :confirm-disabled="freightLoading || !externalFreight" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><DeliveryFeeCoveragePanel v-model="coverageSelection" :preview="coveragePreview" :loading="freightLoading" :disabled="transferSaving || freightLoading" /><p v-if="coveredServices.length">本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。</p><h3>本次新增费用</h3><p v-if="freightLoading">正在读取约定运费…</p><p v-for="quote in externalFreight?.quotes || []" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.incomingNo }}:{{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)</p><div class="transfer-grid"><el-form-item v-if="requiresDeliveryFee" :label="externalFreight?.quotes.length || coverageSelection.length ? '派送费(未覆盖部分,USD)' : '派送费用(USD)'" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="requiresLoadingFee" label="装车费(USD)" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="requiresAppointmentFee" label="买约费用(USD)" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="原运费仅按上方确认的服务范围引用;外接运费按实收约定带入。下方只登记未覆盖货物或服务的新增应收。" type="info" :closable="false" /></el-form></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -62,6 +62,7 @@ const generalCatalogConsumers = [
|
||||
'overseas-master-data/pages/OverseasQuotesView.vue',
|
||||
'overseas-master-data/pages/VehiclesView.vue',
|
||||
'overseas-master-data/pages/VehicleDetailView.vue',
|
||||
'overseas-master-data/components/TransportRouteDialog.vue',
|
||||
'overseas-master-data/pages/DriversView.vue',
|
||||
'overseas-master-data/pages/TripsView.vue',
|
||||
'overseas-master-data/pages/CapacityOffersView.vue',
|
||||
|
||||
Reference in New Issue
Block a user