diff --git a/src/modules/overseas-master-data/CONTRACT_GAPS.md b/src/modules/overseas-master-data/CONTRACT_GAPS.md index 62988101..172c491f 100644 --- a/src/modules/overseas-master-data/CONTRACT_GAPS.md +++ b/src/modules/overseas-master-data/CONTRACT_GAPS.md @@ -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平台授权;只读、不预留,分配写入口再次锁定复验。前后端需一起更新,无新增数据库结构。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 1c3e260a..a5b7f32a 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -148,3 +148,17 @@ 本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。 2026-10-10 拼车简化收口:调度默认待我处理,销售默认可拼车次;登记仍为1080px居中弹框,默认只展开客户和卸货站,已知货量与确认前资料分区展开;只读跟进仅突出当前有权限的主要动作,其他操作与关联单据收起。沿原请求、权限与真实作业节点;本次视觉变更待1920×1080、100%缩放人工截图确认。 + +## 仓储转运上门取货(2026-10-17) + +依据本轮用户确认的仓储转运与车辆排车结合方案。 + +| 页面 | 承载 | 业务与验收边界 | +|---|---|---| +| 车辆排车 | 原列表增加待取货页签,服务端分页 | 同仓选择原转运单;780px居中安排弹框,不新增业务单 | +| 车次详情 | 原独立详情内展示逐票取货进度 | 共用车辆司机分配;发车、送仓、运输结案使用确认框;680px居中取货弹框填写实提和凭证 | +| 平台尾程详情、客户端转运详情 | 原单详情内嵌取货进度 | 只显示本票车辆司机、实提与到仓及凭证;不替代仓内测量状态 | + +取货用途隐藏出库关联、外接预订、装载和余位发布。请求结果未知保留原键与载荷重试,阻止刷新和改派丢失请求。本轮类型检查、构建、单测及本机HTTP验收分别记录,尚未进行人工页面截图视觉验收。 + +2026-10-17 车次资源选择修正:沿原详情分配表单及远程下拉框,每次目录搜索后批量读取当前候选的实际占用;显示空闲、当前车次或占用车次号,别车占用不可选。资格与占用分别显示,保存由原锁定分配复验,不在浏览器从当前分页车次推断空闲。占用查询失败不展示未知候选为可用。单测与本机HTTP分别验证,未做截图视觉验收。 diff --git a/src/modules/overseas-master-data/api/pickups.ts b/src/modules/overseas-master-data/api/pickups.ts new file mode 100644 index 00000000..1e34a3f5 --- /dev/null +++ b/src/modules/overseas-master-data/api/pickups.ts @@ -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 { + 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 = { 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[0] + +/** 待取货列表始终以服务端资格和总数为准。 */ +export async function listPickupCandidates(page: number, size: number, keyword?: string) { + const value = unwrapApiResponse(await http.get, ApiEnvelope>(base, { params: { page, size, keyword } })) + return { records: value.records, total: Number(value.total) } +} +/** 安排成功返回原有车次详情。 */ +export async function createPickupTrip(body: PickupPlan): Promise { + return normalizeTripDetail(unwrapApiResponse(await http.post, ApiEnvelope>(base, body))) +} +/** 车次成员一次读取,避免逐行加载。 */ +export async function getPickupTasks(tripNo: string): Promise { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${tripPath(tripNo)}/tasks`)) +} +/** 运输发车与结案沿取货专用规则执行。 */ +export async function commandPickupTrip(tripNo: string, action: 'depart' | 'complete', body: PickupCommand): Promise { + return normalizeTripDetail(unwrapApiResponse(await http.post, ApiEnvelope>(`${tripPath(tripNo)}/${action}`, body))) +} +/** 取货和送仓逐票确认,保留请求键以支持未知结果重试。 */ +export async function commandPickupTask(tripNo: string, row: PickupSource, action: 'picked' | 'arrive', body: PickupCommand | PickupConfirmation): Promise { + return unwrapApiResponse(await http.post, ApiEnvelope>(`${taskPath(tripNo, row)}/${action}`, body)) +} +/** 文件上传绑定本票与当前车次。 */ +export async function uploadPickupEvidence(tripNo: string, row: PickupSource, file: File): Promise { + const body = new FormData(); body.append('file', file) + return unwrapApiResponse(await http.post, ApiEnvelope>(`${taskPath(tripNo, row)}/files`, body)) +} +/** 本票凭证通过授权入口读取,永不使用公共文件直链。 */ +export async function downloadPickupEvidence(row: PickupSource, fileId: string): Promise { + return http.get(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(row.transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId: row.customerId }, responseType: 'blob' }) +} diff --git a/src/modules/overseas-master-data/api/trips.test.ts b/src/modules/overseas-master-data/api/trips.test.ts index 34654bd2..1da9087f 100644 --- a/src/modules/overseas-master-data/api/trips.test.ts +++ b/src/modules/overseas-master-data/api/trips.test.ts @@ -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 = (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 } diff --git a/src/modules/overseas-master-data/api/trips.ts b/src/modules/overseas-master-data/api/trips.ts index 01643ce3..521057f4 100644 --- a/src/modules/overseas-master-data/api/trips.ts +++ b/src/modules/overseas-master-data/api/trips.ts @@ -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 return page(unwrapApiResponse(await http.get>, ApiEnvelope>>(base, { params: query })), trip) } export async function getTrip(tripNo: string): Promise { - return detail(unwrapApiResponse(await http.get, ApiEnvelope>(path(tripNo)))) + return normalizeTripDetail(unwrapApiResponse(await http.get, ApiEnvelope>(path(tripNo)))) } async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise { - return detail(unwrapApiResponse(await http.post, ApiEnvelope>(url, body))) + return normalizeTripDetail(unwrapApiResponse(await http.post, ApiEnvelope>(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 { + if (!resourceIds.length) return [] + return unwrapApiResponse(await http.get, ApiEnvelope>(`${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 { - return detail(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/plan`, body))) + return normalizeTripDetail(unwrapApiResponse(await http.put, ApiEnvelope>(`${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 { @@ -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 { - return detail(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/prepare`, body))) + return normalizeTripDetail(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/prepare`, body))) } diff --git a/src/modules/overseas-master-data/components/PickupDemandPanel.test.ts b/src/modules/overseas-master-data/components/PickupDemandPanel.test.ts new file mode 100644 index 00000000..4679069c --- /dev/null +++ b/src/modules/overseas-master-data/components/PickupDemandPanel.test.ts @@ -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: '
' }, + AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '
' }, + ElButton: { props: ['disabled'], template: '' }, + ElForm: { template: '
' }, ElFormItem: { template: '
' }, + ElDatePicker: { name: 'ElDatePicker', props: ['modelValue'], emits: ['update:modelValue'], template: '' }, + } } }); 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) +}) diff --git a/src/modules/overseas-master-data/components/PickupDemandPanel.vue b/src/modules/overseas-master-data/components/PickupDemandPanel.vue new file mode 100644 index 00000000..95225533 --- /dev/null +++ b/src/modules/overseas-master-data/components/PickupDemandPanel.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/modules/overseas-master-data/components/TripAssignmentPanel.test.ts b/src/modules/overseas-master-data/components/TripAssignmentPanel.test.ts new file mode 100644 index 00000000..a748ea96 --- /dev/null +++ b/src/modules/overseas-master-data/components/TripAssignmentPanel.test.ts @@ -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: '
' }, ElFormItem: { template: '
' }, + ElSelect: { name: 'ElSelect', props: ['modelValue'], emits: ['focus', 'update:modelValue'], template: '
' }, + ElOption: { name: 'ElOption', props: ['value', 'label', 'disabled'], template: '{{ label }}' }, + ElButton: { template: '' }, + } } }) +} +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() +}) diff --git a/src/modules/overseas-master-data/components/TripAssignmentPanel.vue b/src/modules/overseas-master-data/components/TripAssignmentPanel.vue index d917198d..0460039f 100644 --- a/src/modules/overseas-master-data/components/TripAssignmentPanel.vue +++ b/src/modules/overseas-master-data/components/TripAssignmentPanel.vue @@ -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([]), drivers = ref([]) +const vehicleOccupancy = ref>({}), driverOccupancy = ref>({}) const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false) const vehicleLoading = ref(false), driverLoading = ref(false), error = ref(null) let vehicleRequest = 0, driverRequest = 0, generation = 0 let vehicleTimer: ReturnType | undefined, driverTimer: ReturnType | 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 - + - +
保存分配
diff --git a/src/modules/overseas-master-data/components/TripPickupPanel.test.ts b/src/modules/overseas-master-data/components/TripPickupPanel.test.ts new file mode 100644 index 00000000..019e79d6 --- /dev/null +++ b/src/modules/overseas-master-data/components/TripPickupPanel.test.ts @@ -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: '
' }, + ElButton: { props: ['disabled'], template: '' }, + LoadErrorState: { name: 'LoadErrorState', emits: ['retry'], template: '' }, + } } }) +} +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() +}) diff --git a/src/modules/overseas-master-data/components/TripPickupPanel.vue b/src/modules/overseas-master-data/components/TripPickupPanel.vue new file mode 100644 index 00000000..c2121c8d --- /dev/null +++ b/src/modules/overseas-master-data/components/TripPickupPanel.vue @@ -0,0 +1,132 @@ + + + diff --git a/src/modules/overseas-master-data/pages/TripDetailView.vue b/src/modules/overseas-master-data/pages/TripDetailView.vue index db7d2013..54f2ffbe 100644 --- a/src/modules/overseas-master-data/pages/TripDetailView.vue +++ b/src/modules/overseas-master-data/pages/TripDetailView.vue @@ -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(null), loading = ref(false), error = ref(null), operationError = ref(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 | null>(null) const inspection = ref(null), inspectionLoading = ref(false) @@ -40,6 +42,7 @@ const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new const vehicleStatus = ref('IN_SERVICE') const completionRequest = ref(null), releaseRequest = ref(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 = { 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 = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联', +const actionLabels: Record = { 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): Promise { - 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++ })