发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #21
@@ -14,3 +14,7 @@
|
||||
- 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。
|
||||
- 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。
|
||||
- CSV 下载只接受真实 Blob;JSON 错误 Blob 转为带 `requestId/traceId` 的业务错误,不保存为文件。
|
||||
|
||||
2026-10-17:仓储转运上门取货使用 `fleet-transfer-pickup-v1.yaml` 新接口,需部署Lastleg及 `baiyue_lastleg` 结构增量后生效。本轮只操作本机,不据本机验收推定远端已部署;人工页面视觉待验收。
|
||||
|
||||
2026-10-17 资源占用辅助查询:`GET /fleet/trips/{tripNo}/resource-occupancy`,按VEHICLE/DRIVER和本次最多100个ID批量读取;本车资源可保留、其他车次禁选。沿trip:list平台授权;只读、不预留,分配写入口再次锁定复验。前后端需一起更新,无新增数据库结构。
|
||||
|
||||
@@ -148,3 +148,17 @@
|
||||
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
|
||||
|
||||
2026-10-10 拼车简化收口:调度默认待我处理,销售默认可拼车次;登记仍为1080px居中弹框,默认只展开客户和卸货站,已知货量与确认前资料分区展开;只读跟进仅突出当前有权限的主要动作,其他操作与关联单据收起。沿原请求、权限与真实作业节点;本次视觉变更待1920×1080、100%缩放人工截图确认。
|
||||
|
||||
## 仓储转运上门取货(2026-10-17)
|
||||
|
||||
依据本轮用户确认的仓储转运与车辆排车结合方案。
|
||||
|
||||
| 页面 | 承载 | 业务与验收边界 |
|
||||
|---|---|---|
|
||||
| 车辆排车 | 原列表增加待取货页签,服务端分页 | 同仓选择原转运单;780px居中安排弹框,不新增业务单 |
|
||||
| 车次详情 | 原独立详情内展示逐票取货进度 | 共用车辆司机分配;发车、送仓、运输结案使用确认框;680px居中取货弹框填写实提和凭证 |
|
||||
| 平台尾程详情、客户端转运详情 | 原单详情内嵌取货进度 | 只显示本票车辆司机、实提与到仓及凭证;不替代仓内测量状态 |
|
||||
|
||||
取货用途隐藏出库关联、外接预订、装载和余位发布。请求结果未知保留原键与载荷重试,阻止刷新和改派丢失请求。本轮类型检查、构建、单测及本机HTTP验收分别记录,尚未进行人工页面截图视觉验收。
|
||||
|
||||
2026-10-17 车次资源选择修正:沿原详情分配表单及远程下拉框,每次目录搜索后批量读取当前候选的实际占用;显示空闲、当前车次或占用车次号,别车占用不可选。资格与占用分别显示,保存由原锁定分配复验,不在浏览器从当前分页车次推断空闲。占用查询失败不展示未知候选为可用。单测与本机HTTP分别验证,未做截图视觉验收。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeTripDetail } from './trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
export interface PickupSource { customerId: string; transferNo: string }
|
||||
export interface PickupCandidate extends PickupSource { lastlegOrderNo: string; warehouseId: string; warehouseCode?: string | null; warehouseName?: string | null; pickupAddress: string; contactName: string; contactPhone: string; packageCount: number; expectedArrivalAt: string | null }
|
||||
export interface PickupEvidence { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface PickupTask extends Omit<PickupCandidate, 'expectedArrivalAt'> {
|
||||
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
|
||||
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null; evidence: PickupEvidence[]
|
||||
}
|
||||
export interface PickupPlan { sources: PickupSource[]; plannedDepartureAt: string; expectedDriverEndAt: string; remark?: string; idempotencyKey: string }
|
||||
export interface PickupCommand { recordVersion: number; idempotencyKey: string; reason: string }
|
||||
export interface PickupConfirmation { recordVersion: number; idempotencyKey: string; actualPackages: number; evidenceFileIds: string[]; remark?: string }
|
||||
export const pickupStatusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', WAITING: '待安排取货', ASSIGNED: '已安排取货', PICKED: '已取货', ARRIVED: '已送仓' }
|
||||
const base = '/platform/v1/last-leg/fleet/pickups'
|
||||
const tripPath = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}`
|
||||
const taskPath = (tripNo: string, row: PickupSource) => `${tripPath(tripNo)}/tasks/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}`
|
||||
type Page = { records: PickupCandidate[]; total: string | number }
|
||||
type WireDetail = Parameters<typeof normalizeTripDetail>[0]
|
||||
|
||||
/** 待取货列表始终以服务端资格和总数为准。 */
|
||||
export async function listPickupCandidates(page: number, size: number, keyword?: string) {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<Page>, ApiEnvelope<Page>>(base, { params: { page, size, keyword } }))
|
||||
return { records: value.records, total: Number(value.total) }
|
||||
}
|
||||
/** 安排成功返回原有车次详情。 */
|
||||
export async function createPickupTrip(body: PickupPlan): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(base, body)))
|
||||
}
|
||||
/** 车次成员一次读取,避免逐行加载。 */
|
||||
export async function getPickupTasks(tripNo: string): Promise<PickupTask[]> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${tripPath(tripNo)}/tasks`))
|
||||
}
|
||||
/** 运输发车与结案沿取货专用规则执行。 */
|
||||
export async function commandPickupTrip(tripNo: string, action: 'depart' | 'complete', body: PickupCommand): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${tripPath(tripNo)}/${action}`, body)))
|
||||
}
|
||||
/** 取货和送仓逐票确认,保留请求键以支持未知结果重试。 */
|
||||
export async function commandPickupTask(tripNo: string, row: PickupSource, action: 'picked' | 'arrive', body: PickupCommand | PickupConfirmation): Promise<PickupTask[]> {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${taskPath(tripNo, row)}/${action}`, body))
|
||||
}
|
||||
/** 文件上传绑定本票与当前车次。 */
|
||||
export async function uploadPickupEvidence(tripNo: string, row: PickupSource, file: File): Promise<PickupEvidence> {
|
||||
const body = new FormData(); body.append('file', file)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<PickupEvidence>, ApiEnvelope<PickupEvidence>>(`${taskPath(tripNo, row)}/files`, body))
|
||||
}
|
||||
/** 本票凭证通过授权入口读取,永不使用公共文件直链。 */
|
||||
export async function downloadPickupEvidence(row: PickupSource, fileId: string): Promise<Blob> {
|
||||
return http.get<Blob, Blob>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(row.transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId: row.customerId }, responseType: 'blob' })
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import {
|
||||
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
|
||||
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, getTripResourceOccupancy, bindTripDispatch, unbindTripDispatch, cancelTrip,
|
||||
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
|
||||
} from './trips'
|
||||
|
||||
@@ -26,6 +26,15 @@ const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
|
||||
describe('fleet trip API contract', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('batches occupancy queries without rounding long resource identifiers', async () => {
|
||||
const occupancy = [{ resourceId: longId, occupiedTripNo: 'TR-OTHER', available: false }]
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(occupancy))
|
||||
expect(await getTripResourceOccupancy('TR/TEST', 'DRIVER', [longId, '9007199254740997'])).toEqual(occupancy)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/resource-occupancy`, { params: { kind: 'DRIVER', resourceIds: `${longId},9007199254740997` } })
|
||||
expect(await getTripResourceOccupancy('TR/TEST', 'VEHICLE', [])).toEqual([])
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses one paged request and keeps long identifiers as exact strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' }))
|
||||
const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId }
|
||||
|
||||
@@ -52,7 +52,7 @@ const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer
|
||||
const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) })
|
||||
const batchReferences = (values: TripBinding['appointmentBatches']) => values.map(reference => ({ ...reference, sources: reference.sources.map(source => ({ ...source, packageCount: integer(source.packageCount) })) }))
|
||||
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), appointmentBatches: batchReferences(value.appointmentBatches), review: review(value.review) })
|
||||
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
|
||||
export const normalizeTripDetail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
|
||||
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
|
||||
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
|
||||
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })
|
||||
@@ -71,13 +71,19 @@ export async function listTrips(query: TripQuery): Promise<PageResult<FleetTrip>
|
||||
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
|
||||
}
|
||||
export async function getTrip(tripNo: string): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
|
||||
}
|
||||
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
|
||||
}
|
||||
export const createTrip = (body: TripDraft) => postDetail(base, body)
|
||||
export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body)
|
||||
export interface TripResourceOccupancy { resourceId: string; occupiedTripNo: string | null; available: boolean }
|
||||
/** 当前下拉候选批量查询实际占用,长 ID 始终按字符串传递。 */
|
||||
export async function getTripResourceOccupancy(tripNo: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]): Promise<TripResourceOccupancy[]> {
|
||||
if (!resourceIds.length) return []
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<TripResourceOccupancy[]>, ApiEnvelope<TripResourceOccupancy[]>>(`${path(tripNo)}/resource-occupancy`, { params: { kind, resourceIds: resourceIds.join(',') } }))
|
||||
}
|
||||
export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body)
|
||||
export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body)
|
||||
export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body)
|
||||
@@ -86,7 +92,7 @@ export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason
|
||||
export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body)
|
||||
export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body)
|
||||
export async function updateTripPlan(tripNo: string, body: TripPlan): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
|
||||
}
|
||||
const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) })
|
||||
export async function getTripCapacityPlan(tripNo: string): Promise<TripCapacityPlanning> {
|
||||
@@ -121,5 +127,5 @@ export async function listTripDispatchCandidates(tripNo: string, keyword?: strin
|
||||
}
|
||||
export interface TripPreparation { tripNo: string | null; recordVersion: number | null; plan: TripDraft | null; vehicleId: string | null; driverId: string | null; dispatchNos: string[]; idempotencyKey: string }
|
||||
export async function prepareTrip(body: TripPreparation): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import PickupDemandPanel from './PickupDemandPanel.vue'
|
||||
import { listPickupCandidates, createPickupTrip, type PickupCandidate } from '../api/pickups'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
const state = vi.hoisted(() => ({ push: vi.fn(), write: true }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }), onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
|
||||
vi.mock('../api/pickups', () => ({ listPickupCandidates: vi.fn(), createPickupTrip: vi.fn() }))
|
||||
const rows: PickupCandidate[] = [1, 2].map(i => ({ customerId: `C${i}`, transferNo: `WT-${i}`, lastlegOrderNo: `LL-${i}`, warehouseId: '9007199254740999', pickupAddress: `TEST Ontario ${i}`, contactName: '取货验收员', contactPhone: '000', packageCount: 5, expectedArrivalAt: null }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount() {
|
||||
const wrapper = shallowMount(PickupDemandPanel, { global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total'], emits: ['selection-change'], template: '<div><slot name="toolbar-actions"/><slot name="notices"/></div>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/></div>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElDatePicker: { name: 'ElDatePicker', props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' },
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); state.write = true; vi.mocked(listPickupCandidates).mockResolvedValue({ records: rows, total: 42 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('uses server total and rejects combining different destination warehouses', async () => {
|
||||
const wrapper = mount(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(42)
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', [rows[0], { ...rows[1], warehouseId: 'OTHER' }]); await flushPromises()
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(false)
|
||||
expect(createPickupTrip).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps exact source identities and the same create request after an unknown network result', async () => {
|
||||
const wrapper = mount(); await flushPromises()
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', rows); await flushPromises(); await wrapper.find('button').trigger('click')
|
||||
const dates = wrapper.findAllComponents({ name: 'ElDatePicker' })
|
||||
dates[0]!.vm.$emit('update:modelValue', '2026-10-17T08:00:00Z'); dates[1]!.vm.$emit('update:modelValue', '2026-10-18T08:00:00Z'); await flushPromises()
|
||||
vi.mocked(createPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ trip: { tripNo: 'TR-TEST' } } as TripDetail)
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const request = vi.mocked(createPickupTrip).mock.calls[0]![0]
|
||||
expect(request.sources).toEqual(rows.map(({ customerId, transferNo }) => ({ customerId, transferNo })))
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(vi.mocked(createPickupTrip).mock.calls[1]![0]).toBe(request)
|
||||
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
|
||||
})
|
||||
it('does not show scheduling to read-only users', async () => {
|
||||
state.write = false; const wrapper = mount(); await flushPromises()
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPickupCandidates, createPickupTrip, type PickupCandidate, type PickupPlan } from '../api/pickups'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const rows = ref<PickupCandidate[]>([]), selected = ref<PickupCandidate[]>([]), page = ref(1), total = ref(0), loading = ref(false)
|
||||
const filters = ref<QueryModel>({ keyword: '' }), applied = ref(''), error = ref(''), open = ref(false), busy = ref(false)
|
||||
const departure = ref(''), end = ref(''), remark = ref(''), snapshot = ref<PickupCandidate[]>([]), pending = ref<PickupPlan | null>(null)
|
||||
let sequence = 0
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'selection', type: 'selection', width: 45 }, { key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
|
||||
{ key: 'lastlegOrderNo', prop: 'lastlegOrderNo', label: '尾程单号', minWidth: 200 },
|
||||
{ key: 'warehouseId', prop: 'warehouseId', label: '入仓仓库', minWidth: 165, slotName: 'warehouse' },
|
||||
{ key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 280 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 150 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '期望入仓时间', minWidth: 175 },
|
||||
]
|
||||
/** 分页或条件变化时清除旧选择,迟到响应不能覆盖新页。 */
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = ''; selected.value = []
|
||||
try { const value = await listPickupCandidates(page.value, 20, applied.value || undefined); if (current === sequence) { rows.value = value.records; total.value = value.total } }
|
||||
catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '待取货需求加载失败').message } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
/** 查询只采用明确提交的条件。 */
|
||||
function search() { applied.value = String(filters.value.keyword || '').trim(); if (page.value !== 1) page.value = 1; else void load() }
|
||||
/** 安排弹框固定本次选单快照。 */
|
||||
function arrange() {
|
||||
if (!selected.value.length) { ElMessage.warning('请勾选待取货订单'); return }
|
||||
if (new Set(selected.value.map(row => row.warehouseId)).size !== 1) { ElMessage.warning('同车取货必须送往同一海外仓'); return }
|
||||
snapshot.value = [...selected.value]; pending.value = null; departure.value = ''; end.value = ''; remark.value = ''; error.value = ''; open.value = true
|
||||
}
|
||||
/** 网络结果未知时沿原键原内容重试,业务错误后允许修正输入。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!pending.value && (!departure.value || !end.value)) { ElMessage.warning('请填写取货发车时间和预计任务结束时间'); return }
|
||||
pending.value ||= { sources: snapshot.value.map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date(departure.value).toISOString(), expectedDriverEndAt: new Date(end.value).toISOString(), remark: remark.value.trim(), idempotencyKey: crypto.randomUUID() }
|
||||
busy.value = true
|
||||
try { const value = await createPickupTrip(pending.value); open.value = false; pending.value = null; await router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: value.trip.tripNo } }) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '安排取货未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
/** 未知提交结果必须保留原请求,避免关闭后重复建车次。 */
|
||||
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先重试原安排请求,确认结果后再关闭'); return }; done() }
|
||||
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先确认本次安排结果'); return false }; return true })
|
||||
watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<DataTable v-model:page="page" :page-size="20" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="transferNo" empty-text="暂无已审核的待取货订单" @selection-change="selected = $event">
|
||||
<template #filters><QueryToolbar ariaLabel="待取货需求查询" v-model="filters" :fields="[{ key: 'keyword', type: 'input', label: '关键词', placeholder: '转运单号 / 取货地址', width: 300 }]" @search="search" @reset="search" /></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" :disabled="!selected.length" @click="arrange">安排取货({{ selected.length }})</el-button></template>
|
||||
<template #notices><LoadErrorState v-if="error && !open" title="待取货需求加载失败" :message="error" @retry="load" /></template>
|
||||
<template #warehouse="{ row }">{{ row.warehouseName || '海外仓' }} · {{ row.warehouseCode || row.warehouseId }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
|
||||
<p>已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}</p>
|
||||
<el-form label-position="top" class="pickup-plan-form" :disabled="busy || !!pending">
|
||||
<el-form-item label="计划取货发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="司机预计任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="调度备注"><el-input v-model="remark" maxlength="1000" /></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.pickup-plan-form{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}.pickup-plan-form :deep(.el-date-editor){width:100%}.pickup-plan-form>.el-form-item:last-child{grid-column:1/-1}</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import TripAssignmentPanel from './TripAssignmentPanel.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { assignTrip, getTripResourceOccupancy } from '../api/trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { DriverDetail } from '../driverTypes'
|
||||
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() }))
|
||||
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() }))
|
||||
vi.mock('../api/trips', () => ({ assignTrip: vi.fn(), getTripResourceOccupancy: vi.fn() }))
|
||||
const vehicle: VehicleDetail = { id: '9007199254740999', plateNumber: 'TEST-FREE', model: '26ft', maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45, fuelConsumptionPer100Km: 20, mileageKm: 0, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: '1', updatedAt: '' }
|
||||
const driver: DriverDetail = { id: '9007199254740998', driverNo: 'DR-TEST', details: { name: '验收司机', phone: '000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-12-31', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '1', recordVersion: 0, updatedBy: '1', updatedAt: '', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-12-31' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null }
|
||||
const trip: TripDetail = { trip: { tripNo: 'TR-CURRENT', status: 'PLANNED', warehouseId: '1', warehouseCode: 'WH', warehouseName: '仓库', vehicleId: null, vehiclePlate: null, driverId: null, driverName: null, driverPhone: null, maxPalletSpaces: null, maxLoadKg: null, maxVolumeCbm: null, plannedDepartureAt: '2026-10-18T01:00:00Z', expectedDriverEndAt: '2026-10-18T09:00:00Z', departedAt: null, completedAt: null, stops: [], remark: null, recordVersion: 1, updatedAt: '', vehicleRelease: null, driverRelease: null }, driverQualification: null, dispatches: [], loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }, previousDispatches: [], appointmentHistoryUnavailableReason: null }
|
||||
function mount() {
|
||||
return shallowMount(TripAssignmentPanel, { props: { detail: trip }, global: { stubs: {
|
||||
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElSelect: { name: 'ElSelect', props: ['modelValue'], emits: ['focus', 'update:modelValue'], template: '<div><slot/></div>' },
|
||||
ElOption: { name: 'ElOption', props: ['value', 'label', 'disabled'], template: '<span>{{ label }}</span>' },
|
||||
ElButton: { template: '<button><slot/></button>' },
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers(); vi.clearAllMocks()
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicle], page: 1, size: 30, total: 1, pages: 1 })
|
||||
vi.mocked(listDrivers).mockResolvedValue({ records: [driver], page: 1, size: 30, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('disables resources held by another trip and shows the occupying trip before saving', async () => {
|
||||
vi.mocked(getTripResourceOccupancy).mockImplementation(async (_, kind, ids) => ids.map(resourceId => ({ resourceId, occupiedTripNo: kind === 'VEHICLE' ? 'TR-OTHER' : 'TR-CURRENT', available: kind === 'DRIVER' })))
|
||||
const wrapper = mount()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
selects.forEach(select => select.vm.$emit('focus')); await vi.advanceTimersByTimeAsync(250); await flushPromises()
|
||||
const options = wrapper.findAllComponents({ name: 'ElOption' })
|
||||
expect(options[0]!.props()).toMatchObject({ value: vehicle.id, disabled: true })
|
||||
expect(options[0]!.props('label')).toContain('被车次 TR-OTHER 占用')
|
||||
expect(options[1]!.props('disabled')).toBe(false)
|
||||
expect(options[1]!.props('label')).toContain('当前车次')
|
||||
expect(getTripResourceOccupancy).toHaveBeenCalledTimes(2)
|
||||
selects[0]!.vm.$emit('update:modelValue', vehicle.id); selects[1]!.vm.$emit('update:modelValue', driver.id); await flushPromises()
|
||||
await wrapper.find('button').trigger('click'); expect(assignTrip).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not expose directory candidates as free when occupancy lookup fails', async () => {
|
||||
vi.mocked(getTripResourceOccupancy).mockRejectedValue(new Error('查询占用失败'))
|
||||
const wrapper = mount(); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('focus')
|
||||
await vi.advanceTimersByTimeAsync(250); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { assignTrip } from '../api/trips'
|
||||
import { assignTrip, getTripResourceOccupancy, type TripResourceOccupancy } from '../api/trips'
|
||||
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
@@ -14,41 +14,64 @@ import type { TripDetail } from '../tripTypes'
|
||||
const props = defineProps<{ detail: TripDetail }>()
|
||||
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||||
const vehicleOccupancy = ref<Record<string, TripResourceOccupancy>>({}), driverOccupancy = ref<Record<string, TripResourceOccupancy>>({})
|
||||
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
|
||||
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let vehicleRequest = 0, driverRequest = 0, generation = 0
|
||||
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
|
||||
/** 本车占用可保留,其他车次占用显示具体业务号并禁止选择。 */
|
||||
function occupancyLabel(row: TripResourceOccupancy | undefined): string {
|
||||
if (!row) return ' · 占用待查询'
|
||||
if (!row.available) return ` · 被车次 ${row.occupiedTripNo} 占用`
|
||||
return row.occupiedTripNo ? ' · 当前车次' : ' · 空闲'
|
||||
}
|
||||
watch(() => props.detail.trip, trip => {
|
||||
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
|
||||
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
|
||||
vehicleOccupancy.value = {}; driverOccupancy.value = {}
|
||||
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
|
||||
}, { immediate: true })
|
||||
/** 下拉搜索先批量核对占用,失败不把未知状态的资源当成空闲。 */
|
||||
function searchVehicles(keyword = '') {
|
||||
clearTimeout(vehicleTimer); const request = ++vehicleRequest
|
||||
vehicles.value = []; vehicleOccupancy.value = {}; vehicleLoading.value = true
|
||||
vehicleTimer = setTimeout(async () => {
|
||||
vehicleLoading.value = true
|
||||
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (request === vehicleRequest) vehicles.value = result.records }
|
||||
try {
|
||||
const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' })
|
||||
if (request !== vehicleRequest) return
|
||||
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'VEHICLE', result.records.map(row => row.id))
|
||||
if (request === vehicleRequest) { vehicleOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); vehicles.value = result.records }
|
||||
}
|
||||
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
|
||||
finally { if (request === vehicleRequest) vehicleLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
/** 证照合格与资源空闲分别展示,不能用可派车资格代替未占用。 */
|
||||
function searchDrivers(keyword = '') {
|
||||
clearTimeout(driverTimer); const request = ++driverRequest
|
||||
drivers.value = []; driverOccupancy.value = {}; driverLoading.value = true
|
||||
driverTimer = setTimeout(async () => {
|
||||
driverLoading.value = true
|
||||
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt }); if (request === driverRequest) drivers.value = result.records }
|
||||
try {
|
||||
const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt })
|
||||
if (request !== driverRequest) return
|
||||
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'DRIVER', result.records.map(row => row.id))
|
||||
if (request === driverRequest) { driverOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); drivers.value = result.records }
|
||||
}
|
||||
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
|
||||
finally { if (request === driverRequest) driverLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
/** 候选阶段挡住已知占用,保存仍由后端锁定复验以防同时被其他人分配。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
|
||||
if (vehicleLoading.value || driverLoading.value) { ElMessage.warning('请等待车辆和司机占用查询完成'); return }
|
||||
if (vehicleOccupancy.value[vehicleId.value]?.available === false || driverOccupancy.value[driverId.value]?.available === false) { ElMessage.warning('所选车辆或司机已被其他车次占用,请重新选择'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
|
||||
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
|
||||
if (current === generation) { emit('saved', result); ElMessage.success('车辆和司机已分配,请重新核对装载') }
|
||||
if (current === generation) { emit('saved', result); ElMessage.success(props.detail.trip.tripPurpose === 'PICKUP' ? '车辆和司机已分配,可安排取货发车' : '车辆和司机已分配,请重新核对装载') }
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
@@ -60,11 +83,11 @@ onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTi
|
||||
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
|
||||
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
|
||||
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位`" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :disabled="vehicleOccupancy[vehicle.id]?.available !== true" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位${occupancyLabel(vehicleOccupancy[vehicle.id])}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
|
||||
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}`" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :disabled="driverOccupancy[driver.id]?.available !== true || !driver.qualification.canDispatch" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}${occupancyLabel(driverOccupancy[driver.id])}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
|
||||
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import TripPickupPanel from './TripPickupPanel.vue'
|
||||
import { commandPickupTask, commandPickupTrip, getPickupTasks, type PickupTask } from '../api/pickups'
|
||||
import { getTrip } from '../api/trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
|
||||
vi.mock('../api/trips', () => ({ getTrip: vi.fn() }))
|
||||
vi.mock('../api/pickups', () => ({ getPickupTasks: vi.fn(), commandPickupTask: vi.fn(), commandPickupTrip: vi.fn(), uploadPickupEvidence: vi.fn(), downloadPickupEvidence: vi.fn(), pickupStatusLabels: {} }))
|
||||
const row: PickupTask = { customerId: 'C1', transferNo: 'WT-1', status: 'PICKED', evidence: [], lastlegOrderNo: 'LL-1', warehouseId: 'W1', pickupAddress: 'TEST Pickup Ave', contactName: '验收联系人', contactPhone: '0000000000', packageCount: 3, tripNo: 'TR-1', vehiclePlate: 'TEST-V1', driverName: '验收司机', driverPhone: '0000000000', plannedDepartureAt: null, actualPackages: 3, pickedAt: null, arrivedAt: null, remark: null }
|
||||
const detail = (status: string) => ({ trip: { tripNo: 'TR-1', status, recordVersion: 2, vehicleId: 'V1', driverId: 'D1' } }) as TripDetail
|
||||
function mount(status: string) {
|
||||
return shallowMount(TripPickupPanel, { props: { detail: detail(status) }, global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot v-for="row in data" name="actions" :row="row"/></div>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
LoadErrorState: { name: 'LoadErrorState', emits: ['retry'], template: '<button @click="$emit(\'retry\')">重试读取</button>' },
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getPickupTasks).mockResolvedValue([row]) })
|
||||
|
||||
it('locks parent actions and retries the exact departure command after an unknown result', async () => {
|
||||
vi.mocked(commandPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
|
||||
const wrapper = mount('PLANNED'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认取货发车')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('locked')?.at(-1)).toEqual([true])
|
||||
const body = vi.mocked(commandPickupTrip).mock.calls[0]![2]
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试原操作')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandPickupTrip).mock.calls[1]![2]).toBe(body)
|
||||
expect(wrapper.emitted('locked')?.at(-1)).toEqual([false])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('retries only reading after arrival committed but the trip refresh failed', async () => {
|
||||
vi.mocked(commandPickupTask).mockResolvedValue([{ ...row, status: 'ARRIVED' }])
|
||||
vi.mocked(getTrip).mockRejectedValueOnce(new Error('refresh lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
|
||||
const wrapper = mount('IN_TRANSIT'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认送仓')!.trigger('click'); await flushPromises()
|
||||
expect(commandPickupTask).toHaveBeenCalledTimes(1)
|
||||
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||
expect(commandPickupTask).toHaveBeenCalledTimes(1)
|
||||
expect(getTrip).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTrip } from '../api/trips'
|
||||
import { getPickupTasks, commandPickupTrip, commandPickupTask, uploadPickupEvidence, downloadPickupEvidence, pickupStatusLabels,
|
||||
type PickupTask, type PickupCommand, type PickupConfirmation, type PickupEvidence } from '../api/pickups'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
const props = defineProps<{ detail: TripDetail }>(), emit = defineEmits<{ saved: [detail: TripDetail]; locked: [value: boolean] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<PickupTask[]>([]), loading = ref(false), busy = ref(false), error = ref('')
|
||||
const selected = ref<PickupTask | null>(null), open = ref(false), actual = ref(1), remark = ref(''), evidence = ref<PickupEvidence[]>([])
|
||||
const needsTripRefresh = ref(false)
|
||||
const pending = ref<{ action: 'depart' | 'complete' | 'picked' | 'arrive'; row: PickupTask | null; body: PickupCommand | PickupConfirmation } | null>(null)
|
||||
// 命令结果未知时阻止父页面刷新和改派,保留原请求用于幂等重试。
|
||||
watch(() => busy.value || !!pending.value, value => emit('locked', value), { flush: 'sync' })
|
||||
let sequence = 0
|
||||
const canComplete = computed(() => rows.value.length > 0 && rows.value.every(row => row.status === 'ARRIVED'))
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 175, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
|
||||
{ key: 'status', label: '取货进度', slotName: 'status', width: 110 }, { key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 270 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 145 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'actualPackages', label: '实提箱数', slotName: 'actual', width: 100 },
|
||||
{ key: 'evidence', label: '取货凭证', slotName: 'evidence', minWidth: 180 }, { key: 'remark', prop: 'remark', label: '取货备注', minWidth: 180 },
|
||||
]
|
||||
/** 车次变化时丢弃旧请求;同车版本变化保留未确定结果的命令。 */
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = ''
|
||||
try {
|
||||
if (needsTripRefresh.value) {
|
||||
const detail = await getTrip(props.detail.trip.tripNo)
|
||||
if (current !== sequence) return
|
||||
emit('saved', detail); needsTripRefresh.value = false
|
||||
}
|
||||
const value = await getPickupTasks(props.detail.trip.tripNo); if (current === sequence) rows.value = value
|
||||
}
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货任务加载失败').message }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
/** 逐票录入实提,默认箱数仅供编辑,需操作员确认。 */
|
||||
function edit(row: PickupTask) {
|
||||
if (pending.value) return
|
||||
selected.value = row; actual.value = row.packageCount; remark.value = ''; evidence.value = []; open.value = true
|
||||
}
|
||||
/** 凭证上传与确认分开,重试确认不重新上传文件。 */
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]
|
||||
if (!file || !selected.value || busy.value) return
|
||||
busy.value = true; error.value = ''; const current = sequence
|
||||
try { const value = await uploadPickupEvidence(props.detail.trip.tripNo, selected.value, file); if (current === sequence) evidence.value.push(value) }
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货凭证上传失败').message }
|
||||
finally { if (current === sequence) busy.value = false; input.value = '' }
|
||||
}
|
||||
/** 失败结果未知时固定原请求,成功后只刷新车次版本和成员。 */
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value) return
|
||||
const request = pending.value, current = sequence, number = props.detail.trip.tripNo
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
if (request.action === 'depart' || request.action === 'complete') {
|
||||
const detail = await commandPickupTrip(number, request.action, request.body as PickupCommand)
|
||||
if (current === sequence) { emit('saved', detail); pending.value = null }
|
||||
} else if (request.row) {
|
||||
const tasks = await commandPickupTask(number, request.row, request.action, request.body)
|
||||
if (current !== sequence) return
|
||||
// 业务命令已经成功,后续刷新失败只重试读取,不能再次生成执行命令。
|
||||
rows.value = tasks; pending.value = null; open.value = false; needsTripRefresh.value = true
|
||||
const detail = await getTrip(number); if (current === sequence) { emit('saved', detail); needsTripRefresh.value = false }
|
||||
}
|
||||
} catch (cause) {
|
||||
if (current === sequence) { error.value = getApiErrorDetails(cause, '取货操作未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
|
||||
} finally { if (current === sequence) busy.value = false }
|
||||
}
|
||||
/** 发车、送仓和结案提交前说明本次事实。 */
|
||||
async function command(action: 'depart' | 'arrive' | 'complete', row: PickupTask | null = null) {
|
||||
if (pending.value || busy.value || needsTripRefresh.value) return
|
||||
const messages = { depart: '确认车辆和司机已出发执行本车取货任务?', arrive: '确认该单货物已送达选定海外仓?确认后仓库可开始 PDA 清点。', complete: '确认本车全部取货订单已送仓,结束本次取货运输?' }
|
||||
try { await ElMessageBox.confirm(messages[action], '确认取货运输', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
pending.value = { action, row, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), reason: messages[action] } }
|
||||
await execute()
|
||||
}
|
||||
/** 照片和实提箱数为取货确认的必要信息。 */
|
||||
async function confirmPickup() {
|
||||
if (pending.value) { await execute(); return }
|
||||
if (needsTripRefresh.value) { ElMessage.warning('请先刷新车次后继续'); return }
|
||||
if (!selected.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length) { ElMessage.warning('请填写实提箱数并上传至少一份取货凭证'); return }
|
||||
pending.value = { action: 'picked', row: selected.value, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: remark.value.trim() } }
|
||||
await execute()
|
||||
}
|
||||
/** 只下载本票确认的文件,使用完即释放临时地址。 */
|
||||
async function download(row: PickupTask, file: PickupEvidence) {
|
||||
try { const blob = await downloadPickupEvidence(row, file.fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败').message }
|
||||
}
|
||||
/** 原命令结果未知时保持弹框与请求快照。 */
|
||||
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先确认原取货操作的结果'); return }; done() }
|
||||
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先重试并确认原取货操作'); return false }; return true })
|
||||
watch(() => props.detail.trip.tripNo, () => { pending.value = null; open.value = false; rows.value = []; void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="本车仓储转运取货任务">
|
||||
<div class="density-actions pickup-actions">
|
||||
<el-button v-if="detail.trip.status === 'PLANNED' && access.can(permission.tripAssign)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !detail.trip.vehicleId || !detail.trip.driverId" @click="command('depart')">确认取货发车</el-button>
|
||||
<el-button v-if="detail.trip.status === 'IN_TRANSIT' && access.can(permission.tripComplete)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !canComplete" @click="command('complete')">取货运输结案</el-button>
|
||||
<el-button :disabled="busy || !!pending" @click="load">刷新取货任务</el-button>
|
||||
<el-button v-if="pending && !open" type="primary" :loading="busy" @click="execute">重试原操作</el-button>
|
||||
</div>
|
||||
<LoadErrorState v-if="error && !open" title="取货操作未完成" :message="error" @retry="pending ? execute() : load()" />
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="transferNo" empty-text="暂无取货成员">
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'ASSIGNED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="edit(row as PickupTask)">确认取货</el-button><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'PICKED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="command('arrive', row as PickupTask)">确认送仓</el-button></div></template>
|
||||
<template #status="{ row }">{{ pickupStatusLabels[row.status] || row.status }}</template><template #actual="{ row }">{{ row.actualPackages ?? '待取货' }}</template>
|
||||
<template #evidence="{ row }"><div v-for="file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)">{{ file.fileName }}</el-button></div></template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
|
||||
<p>{{ selected?.transferNo }} · {{ selected?.pickupAddress }}</p>
|
||||
<el-form label-position="top" :disabled="busy || !!pending">
|
||||
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" /></el-form-item>
|
||||
<el-form-item label="取货凭证" required><div><input type="file" accept="image/png,image/jpeg,application/pdf" :disabled="busy || !!pending || evidence.length >= 10" @change="upload"><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></div></el-form-item>
|
||||
<el-form-item label="取货备注"><el-input v-model="remark" type="textarea" maxlength="1000" /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.pickup-actions{margin-bottom:16px}</style>
|
||||
@@ -15,6 +15,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import TripPickupPanel from '../components/TripPickupPanel.vue'
|
||||
import TripAssignmentPanel from '../components/TripAssignmentPanel.vue'
|
||||
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
|
||||
import TripBookingPanel from '../components/TripBookingPanel.vue'
|
||||
@@ -32,6 +33,7 @@ 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 pickupLocked = ref(false)
|
||||
const appointmentsOpen = ref(false)
|
||||
const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null)
|
||||
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
|
||||
@@ -40,6 +42,7 @@ const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new
|
||||
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
|
||||
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
|
||||
let generation = 0, inspectionSequence = 0, historySequence = 0
|
||||
const isPickup = computed(() => detail.value?.trip.tripPurpose === 'PICKUP')
|
||||
const planned = computed(() => detail.value?.trip.status === 'PLANNED')
|
||||
const editable = computed(() => planned.value && access.can(permission.tripEdit))
|
||||
const time = (value: string | null | undefined) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
@@ -58,7 +61,7 @@ const outcomeLabels: Record<string, string> = { DELIVERED: '已签收', RETURNED
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作时间', slotName: 'time', minWidth: 175 },
|
||||
{ key: 'action', label: '操作', slotName: 'action', minWidth: 145 }, { key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
|
||||
{ key: 'vehicle', prop: 'snapshot.trip.vehiclePlate', label: '当时车辆', minWidth: 145 }, { key: 'driver', prop: 'snapshot.trip.driverName', label: '当时司机', minWidth: 125 }]
|
||||
const actionLabels: Record<string, string> = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
|
||||
const actionLabels: Record<string, string> = { PICKUP_PLAN: '安排上门取货', PICKUP_DEPART: '取货发车', PICKUP_PICKED: '确认取货', PICKUP_ARRIVED: '确认送仓', PICKUP_COMPLETE: '取货运输结案', CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
|
||||
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
|
||||
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
|
||||
function accept(value: TripDetail) {
|
||||
@@ -67,6 +70,7 @@ function accept(value: TripDetail) {
|
||||
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
|
||||
}
|
||||
async function load() {
|
||||
if (pickupLocked.value) { ElMessage.warning('请先重试并确认原取货操作'); return }
|
||||
if (appointmentsPanel.value && !await appointmentsPanel.value.canLeave()) return
|
||||
const current = ++generation; inspectionSequence++; historySequence++
|
||||
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
|
||||
@@ -78,7 +82,7 @@ async function load() {
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function run(action: string, command: () => Promise<TripDetail>): Promise<boolean> {
|
||||
if (busy.value) return false
|
||||
if (busy.value || pickupLocked.value) return false
|
||||
const current = generation; busy.value = action; operationError.value = null
|
||||
try { const result = await command(); if (current !== generation) return false; accept(result); return true }
|
||||
catch (cause) {
|
||||
@@ -185,28 +189,29 @@ async function loadHistory() {
|
||||
}
|
||||
watch(() => route.params.tripNo, load, { immediate: true })
|
||||
watch(() => route.query.tab, tab => { activeTab.value = tab === 'loading' ? 'loading' : 'overview' })
|
||||
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
|
||||
watch(activeTab, tab => { if (tab === 'completion' && !isPickup.value) void inspect(); if (tab === 'history') void loadHistory() })
|
||||
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
|
||||
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(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</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>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</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 || pickupLocked" @click="load">刷新车次</el-button><el-button v-if="!isPickup && access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy || pickupLocked" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || pickupLocked || !!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">
|
||||
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [['起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
|
||||
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [[isPickup ? '入仓仓库' : '起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
|
||||
<p>{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}</p><p v-if="detail.driverQualification">{{ driverQualificationLabels[detail.driverQualification.reason] }}<span v-if="detail.driverQualification.expiringSoon"> · 证照将在 30 天内到期,请留意任务延期</span></p>
|
||||
</FormSection>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<template v-if="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<div v-if="activeTab === 'overview' || isPickup" v-show="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel v-if="!pickupLocked" :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<TripPickupPanel v-if="isPickup" :detail="detail" @saved="accept" @locked="pickupLocked = $event" />
|
||||
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
||||
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
</template>
|
||||
<FormSection v-show="activeTab === 'overview'" v-if="access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
|
||||
</div>
|
||||
<FormSection v-show="activeTab === 'overview'" v-if="!isPickup && access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
|
||||
<el-button v-if="!appointmentsOpen" @click="appointmentsOpen = true">核对客户预约与后送去向</el-button>
|
||||
<TripAppointmentPanel v-if="appointmentsOpen" ref="appointmentsPanel" :key="detail.trip.tripNo" :dispatches="detail.dispatches" :previous-dispatches="detail.previousDispatches" :history-unavailable-reason="detail.appointmentHistoryUnavailableReason" :can-write="planned && !busy" @updated="refreshLinked" @work="key => router.push({ path: '/appointment-calendar', query: { workItem: key } })" @external="path => router.push(path)" />
|
||||
</FormSection>
|
||||
@@ -216,7 +221,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
<FormSection v-if="activeTab === 'capacity'" class="density-form-section" title="核对本车计划载荷"><TripLoadingPanel :key="`${detail.trip.tripNo}-capacity`" :trip="detail.trip" :editable="editable" planning @planned="capacityPlanned" /></FormSection>
|
||||
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
|
||||
<template v-if="activeTab === 'completion'">
|
||||
<FormSection v-if="detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
|
||||
<FormSection v-if="!isPickup && detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
|
||||
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
|
||||
|
||||
@@ -10,6 +10,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import PickupDemandPanel from '../components/PickupDemandPanel.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listTrips, type TripQuery } from '../api/trips'
|
||||
@@ -17,6 +18,8 @@ import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { tripStatusLabels, type FleetTrip, type TripDetail } from '../tripTypes'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const workTab = ref('trips'), pickupVisited = ref(false)
|
||||
watch(workTab, value => { if (value === 'pickups') pickupVisited.value = true })
|
||||
const rows = ref<FleetTrip[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), editorOpen = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), filters = ref<QueryModel>({ keyword: '', status: '' })
|
||||
let applied: Omit<TripQuery, 'page' | 'size'> = {}, sequence = 0
|
||||
@@ -24,7 +27,7 @@ const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: Object.entries(tripStatusLabels).map(([value, label]) => ({ value, label })) }]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 215 },
|
||||
{ key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'purpose', label: '任务类型', slotName: 'purpose', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'vehiclePlate', label: '车牌', minWidth: 130 }, { key: 'driverName', label: '司机', minWidth: 110 },
|
||||
{ key: 'departure', label: '计划发车', slotName: 'departure', minWidth: 175 }, { key: 'end', label: '司机预计结束', slotName: 'end', minWidth: 175 },
|
||||
{ key: 'route', label: '路线', slotName: 'route', minWidth: 240 }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
@@ -46,12 +49,15 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="车次管理">
|
||||
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
|
||||
<el-tabs v-model="workTab" class="density-element-tabs"><el-tab-pane label="车次管理" name="trips" /><el-tab-pane label="待取货" name="pickups" /></el-tabs>
|
||||
<PickupDemandPanel v-if="pickupVisited" v-show="workTab === 'pickups'" />
|
||||
<DataTable v-show="workTab === 'trips'" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>车次管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" :loading="loading" @search="search" @reset="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" @click="editorOpen = true">新建车次</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" @click="view(row.tripNo)">详情</el-button></div></template>
|
||||
<template #purpose="{ row }">{{ row.tripPurpose === 'PICKUP' ? '入仓取货' : '出库派送' }}</template>
|
||||
<template #status="{ row }">{{ tripStatusLabels[row.status as keyof typeof tripStatusLabels] }}</template>
|
||||
<template #departure="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #end="{ row }">{{ time(row.expectedDriverEndAt) }}</template>
|
||||
<template #route="{ row }">{{ row.stops.map((stop: FleetTrip['stops'][number]) => stop.name).join(' → ') }}</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface TripStop { name: string; address?: string | null; destinationKe
|
||||
export type ReleasedVehicleStatus = 'IN_SERVICE' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
|
||||
export interface TripReleaseInfo { actualEndAt: string; endLocation: string; vehicleStatus: ReleasedVehicleStatus | null }
|
||||
export interface FleetTrip {
|
||||
tripPurpose?: 'DELIVERY' | 'PICKUP'
|
||||
tripNo: string
|
||||
status: TripStatus
|
||||
warehouseId: string
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface PickupProgress {
|
||||
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
|
||||
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null
|
||||
evidence: { fileId: string; fileName: string }[]
|
||||
}
|
||||
/** 原单详情只查询本票取货进度。 */
|
||||
export async function getTransferPickupProgress(transferNo: string, customerId: string): Promise<PickupProgress | null> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<PickupProgress | null>, ApiEnvelope<PickupProgress | null>>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/pickup-progress`, { params: { customerId } }))
|
||||
}
|
||||
/** 下载当前票确认的取货凭证。 */
|
||||
export async function downloadTransferPickupEvidence(transferNo: string, customerId: string, fileId: string): Promise<Blob> {
|
||||
return http.get<Blob, Blob>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId }, responseType: 'blob' })
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import TransferPickupProgressPanel from './TransferPickupProgressPanel.vue'
|
||||
import { getTransferPickupProgress, type PickupProgress } from '../api/transferPickups'
|
||||
|
||||
vi.mock('../api/transferPickups', () => ({ getTransferPickupProgress: vi.fn(), downloadTransferPickupEvidence: vi.fn() }))
|
||||
const progress = (tripNo: string): PickupProgress => ({ tripNo, status: 'ARRIVED', vehiclePlate: 'TEST-1', driverName: '验收司机', driverPhone: '000', plannedDepartureAt: null, actualPackages: 5, pickedAt: null, arrivedAt: null, remark: null, evidence: [] })
|
||||
it('ignores an old customer response after switching the order identity', async () => {
|
||||
let resolve!: (value: PickupProgress) => void
|
||||
vi.mocked(getTransferPickupProgress).mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValueOnce(progress('TR-NEW'))
|
||||
const wrapper = shallowMount(TransferPickupProgressPanel, { props: { customerId: 'C1', transferNo: 'WT-OLD' }, global: { stubs: { ElButton: { template: '<button><slot/></button>' } } } })
|
||||
await wrapper.setProps({ customerId: 'C2', transferNo: 'WT-NEW' }); await flushPromises(); resolve(progress('TR-OLD')); await flushPromises()
|
||||
expect(wrapper.text()).toContain('TR-NEW'); expect(wrapper.text()).not.toContain('TR-OLD')
|
||||
expect(getTransferPickupProgress).toHaveBeenLastCalledWith('WT-NEW', 'C2'); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTransferPickupProgress, downloadTransferPickupEvidence, type PickupProgress } from '../api/transferPickups'
|
||||
|
||||
const props = defineProps<{ transferNo: string; customerId: string }>()
|
||||
const progress = ref<PickupProgress | null>(null), error = ref(''), loading = ref(false)
|
||||
const labels: Record<string, string> = { PENDING_REVIEW: '待审核', WAITING: '待安排取货', ASSIGNED: '已安排取货', PICKED: '已取货', ARRIVED: '已送仓' }
|
||||
const time = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
let sequence = 0
|
||||
/** 业务号变化后忽略旧响应,客户之间不复用取货数据。 */
|
||||
async function load() {
|
||||
const current = ++sequence; progress.value = null; error.value = ''; loading.value = true
|
||||
try { const value = await getTransferPickupProgress(props.transferNo, props.customerId); if (current === sequence) progress.value = value }
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货进度加载失败').message }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
/** 凭证读取必须经过原单鉴权。 */
|
||||
async function download(file: { fileId: string; fileName: string }) {
|
||||
try { const blob = await downloadTransferPickupEvidence(props.transferNo, props.customerId, file.fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '取货凭证下载失败').message }
|
||||
}
|
||||
watch(() => [props.customerId, props.transferNo], load, { immediate: true }); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<section v-if="loading || progress || error" v-loading="loading" class="pickup-progress" aria-label="取货安排">
|
||||
<div class="pickup-heading"><h3>取货安排</h3><el-button size="small" :disabled="loading" @click="load">刷新进度</el-button></div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<dl v-if="progress" class="pickup-fields">
|
||||
<div><dt>取货进度</dt><dd>{{ labels[progress.status] || progress.status }}</dd></div><div><dt>车次号</dt><dd>{{ progress.tripNo || '待安排' }}</dd></div>
|
||||
<div><dt>车牌</dt><dd>{{ progress.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ progress.driverName || '待分配' }}</dd></div>
|
||||
<div><dt>司机电话</dt><dd>{{ progress.driverPhone || '—' }}</dd></div><div><dt>计划取货发车</dt><dd>{{ time(progress.plannedDepartureAt) }}</dd></div>
|
||||
<div><dt>实提箱数</dt><dd>{{ progress.actualPackages ?? '待取货' }}</dd></div><div><dt>取货时间</dt><dd>{{ time(progress.pickedAt) }}</dd></div>
|
||||
<div><dt>送仓时间</dt><dd>{{ time(progress.arrivedAt) }}</dd></div><div v-if="progress.remark"><dt>取货备注</dt><dd>{{ progress.remark }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="progress?.evidence.length" class="pickup-files"><span>取货凭证</span><el-button v-for="file in progress.evidence" :key="file.fileId" link type="primary" @click="download(file)">{{ file.fileName }}</el-button></div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.pickup-progress{padding:16px;border:1px solid var(--border,#e5e7eb);border-radius:6px;margin:16px 0}.pickup-heading{display:flex;justify-content:space-between;align-items:center}.pickup-heading h3{margin:0;font-size:15px}.pickup-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 20px;margin:16px 0 0}.pickup-fields>div{display:flex;gap:10px}.pickup-fields dt{color:var(--muted,#64748b);flex-shrink:0}.pickup-fields dd{margin:0;overflow-wrap:anywhere}.pickup-files{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px}</style>
|
||||
@@ -22,6 +22,7 @@ import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue'
|
||||
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
|
||||
import WarehouseTransferReceiptPanel from '../components/WarehouseTransferReceiptPanel.vue'
|
||||
import TransferPickupProgressPanel from '../components/TransferPickupProgressPanel.vue'
|
||||
|
||||
interface DetailField { label: string; value: string | number; wide?: boolean }
|
||||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||||
@@ -269,6 +270,7 @@ onMounted(load)
|
||||
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
|
||||
<el-tabs v-model="activeTab" class="detail-tabs density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="basic"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>基本信息</span></template>
|
||||
<TransferPickupProgressPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" />
|
||||
<section aria-label="商业来源与关联单据">
|
||||
<h2 class="source-heading">关联单据</h2>
|
||||
<dl class="detail-fields density-fields">
|
||||
|
||||
Reference in New Issue
Block a user