发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #21
@@ -19,4 +19,9 @@ it('normalizes quote source without overwriting negotiated price and handles man
|
||||
expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25)
|
||||
expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull()
|
||||
expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } })
|
||||
const received = normalizeBooking({ ...row, status: 'RECEIVED', receipt: {
|
||||
source: { incomingNo: 'WT-1', inboundCode: 'IN-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'READY_FOR_DISPATCH', receiptHash: 'merged', receivedPackages: '1', pallets: [{ warehousePalletId: '9007199254740997', palletNo: 'PALLET-MERGED', packages: '1', weightKg: '12.345', volumeCbm: '0.123' }] },
|
||||
pallets: [{ warehousePalletId: '9007199254740997', palletSpaces: '2', weightKg: '12.345', volumeCbm: '0.123' }], operatorId: 'operator', confirmedAt: '',
|
||||
} })
|
||||
expect(received.receipt?.pallets[0]).toEqual({ warehousePalletId: '9007199254740997', palletSpaces: 2, weightKg: 12.345, volumeCbm: 0.123 })
|
||||
})
|
||||
|
||||
@@ -7,13 +7,15 @@ type N = number | string
|
||||
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
|
||||
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
|
||||
export type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
|
||||
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
|
||||
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N; weightKg?: N | null; volumeCbm?: N | null }[] }
|
||||
export type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
|
||||
const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result }
|
||||
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
|
||||
const nullable = (value: N | null) => value == null ? null : number(value)
|
||||
const normalizeReceiptPallet = ({ weightKg, volumeCbm, ...row }: WireReceipt['pallets'][number]) => ({ ...row, palletSpaces: integer(row.palletSpaces),
|
||||
...(weightKg === undefined ? {} : { weightKg: nullable(weightKg) }), ...(volumeCbm === undefined ? {} : { volumeCbm: nullable(volumeCbm) }) })
|
||||
export const normalizeIncoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(normalizeReceiptPallet) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
|
||||
export const normalizeBookingResult = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
|
||||
export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
|
||||
|
||||
@@ -16,7 +16,7 @@ export const bookingStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认
|
||||
|
||||
export interface TripIncomingPallet { warehousePalletId: string; palletNo: string; packages: number; weightKg: number | null; volumeCbm: number | null }
|
||||
export interface TripIncomingEvidence { incomingNo: string; inboundCode: string; warehouseId: string; destinationKey: string; status: string; receiptHash: string; pallets: TripIncomingPallet[]; receivedPackages: number }
|
||||
export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number }
|
||||
export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number; weightKg?: number | null; volumeCbm?: number | null }
|
||||
export interface TripAcceptedReceipt { source: TripIncomingEvidence; pallets: TripPalletSpaces[]; operatorId: string; confirmedAt: string }
|
||||
export interface TripIncomingPreview { tripRecordVersion: number; recordVersion: number; incoming: TripIncomingEvidence | null }
|
||||
export interface TripIncomingStart extends Omit<TripBookingCommand, 'reason'> { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
|
||||
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
|
||||
import { ElInput } from 'element-plus/es/components/input/index'
|
||||
@@ -9,9 +9,12 @@ import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const writable = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => writable.value }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
|
||||
const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
|
||||
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
|
||||
const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
|
||||
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
|
||||
@@ -21,12 +24,12 @@ function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||||
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/><slot name="weightKg" :row="row"/><slot name="volumeCbm" :row="row"/></div></div>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
|
||||
ElButton: { template: '<button><slot/></button>' }, LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
|
||||
beforeEach(() => { vi.resetAllMocks(); writable.value = true; vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
|
||||
@@ -115,3 +118,82 @@ it('compares real receipt with the forecast, preserves unknown values, and does
|
||||
expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false)
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires explicit acceptance of a changed received snapshot and submits the current complete pallets and versions', async () => {
|
||||
const previous = { ...booking, status: 'RECEIVED' as const, receipt: { source: { ...preview.incoming!, receiptHash: 'previous-hash' }, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }], operatorId: 'operator', confirmedAt: '2026-10-01T00:00:00Z' } }
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 11, recordVersion: 8, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-hash', pallets: [
|
||||
{ warehousePalletId: '903', palletNo: 'PLT-MERGED', packages: 22, weightKg: 150, volumeCbm: 1.5 },
|
||||
] } })
|
||||
const wrapper = mount('receipt'); await wrapper.setProps({ booking: previous }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('上次核实与当前实收对照')
|
||||
expect(wrapper.text()).not.toContain('无法还原原始预报')
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 12, booking: { ...previous, recordVersion: 9 }, replayed: false })
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(ElMessageBox.confirm).toHaveBeenLastCalledWith(expect.stringContaining('50.00 USD'), '确认实收差异', expect.any(Object))
|
||||
expect(api.acceptTripIncoming).toHaveBeenCalledExactlyOnceWith('TR-1', 'FB-1', { tripRecordVersion: 11, recordVersion: 8, idempotencyKey: expect.any(String), receiptHash: 'merged-hash', unitPrice: 50, pallets: [{ warehousePalletId: '903', palletSpaces: 1 }] })
|
||||
})
|
||||
|
||||
it('uses a new key after a completed confirmation while preserving the original key for unknown retries', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 6, recordVersion: 3, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'new-hash' } })
|
||||
await wrapper.setProps({ modelValue: true, booking: { ...booking, status: 'RECEIVED', recordVersion: 3 } }); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const next = vi.mocked(api.acceptTripIncoming).mock.calls[1]![2]
|
||||
expect(next.idempotencyKey).not.toBe(first.idempotencyKey)
|
||||
expect(next).toMatchObject({ tripRecordVersion: 6, recordVersion: 3, receiptHash: 'new-hash' })
|
||||
})
|
||||
|
||||
it('does not report success when the server rejects the updated receipt capacity', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockRejectedValue(new ApiResponseError('当前车次容量不足,请重新核对', 'A0409', 409, null, 'capacity-request'))
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(ElMessage.success).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toContain('当前车次容量不足')
|
||||
expect((wrapper.vm as unknown as { error: { requestId: string } }).error).toMatchObject({ requestId: 'capacity-request' })
|
||||
})
|
||||
|
||||
it.each(['bound', 'departed', 'permission'] as const)('rejects a receipt write if the context changes to %s after opening', async context => {
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
if (context === 'bound') await wrapper.setProps({ booking: { ...booking, status: 'BOUND', dispatchNo: 'DS-1' } })
|
||||
if (context === 'departed') await wrapper.setProps({ trip: { ...trip, status: 'IN_TRANSIT' } })
|
||||
if (context === 'permission') {
|
||||
writable.value = false
|
||||
await wrapper.setProps({ booking: { ...booking, recordVersion: 3 } })
|
||||
}
|
||||
await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('requires new measurements for changed pallet composition and keeps them in the exact unknown-result retry', async () => {
|
||||
const merged = { ...preview, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletNo: 'MERGED-1', packages: 22, weightKg: null, volumeCbm: null }] } }
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue(merged)
|
||||
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
|
||||
const wrapper = mount('receipt'); await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } }); await flushPromises()
|
||||
const confirm = () => wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm')
|
||||
confirm(); await flushPromises()
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('重新填写'))
|
||||
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
|
||||
fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测重量')!.vm.$emit('update:modelValue', 123.456)
|
||||
fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测体积')!.vm.$emit('update:modelValue', 1.234)
|
||||
await flushPromises(); confirm(); await flushPromises()
|
||||
const original = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
|
||||
expect(original).toMatchObject({ receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletSpaces: 1, weightKg: 123.456, volumeCbm: 1.234 }] })
|
||||
confirm(); await flushPromises()
|
||||
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
|
||||
})
|
||||
|
||||
it('does not offer measurement overrides for unchanged source pallets', async () => {
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).some(field => /实测重量|实测体积/.test(field.attributes('aria-label') || ''))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -12,36 +14,46 @@ import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'> & Partial<Pick<FleetTrip, 'status'>>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
|
||||
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
|
||||
const measurements = reactive({ weightKg: {} as Record<string, number | undefined>, volumeCbm: {} as Record<string, number | undefined> })
|
||||
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
|
||||
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
|
||||
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, measurements, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
|
||||
defineExpose({ hasPending: computed(() => !!pending.value) })
|
||||
let generation = 0
|
||||
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
|
||||
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '确认排入本车', view: '外接来货详情' }
|
||||
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
|
||||
const receiptAllowed = computed(() => props.pool || props.trip.status === 'PLANNED' && !!props.booking
|
||||
&& ['CONFIRMED', 'RECEIVED'].includes(props.booking.status) && !!props.booking.incomingNo && !props.booking.dispatchNo && access.can(permission.tripBookingConfirm))
|
||||
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
|
||||
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
|
||||
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
|
||||
const receiptComparison = computed(() => {
|
||||
const incoming = preview.value?.incoming
|
||||
const pallets = incoming?.pallets || []
|
||||
const forecast = props.booking?.status === 'CONFIRMED' ? props.booking.cargo : null
|
||||
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => row[key] != null && Number.isFinite(row[key]))
|
||||
? pallets.reduce((total, row) => total + row[key]!, 0) : null
|
||||
const previous = props.booking?.status === 'RECEIVED' ? props.booking.receipt : null
|
||||
const forecast = props.booking?.status === 'CONFIRMED' || previous ? props.booking?.cargo : null
|
||||
const measured = (row: typeof pallets[number], key: 'weightKg' | 'volumeCbm') => row[key] ?? measurements[key][row.warehousePalletId]
|
||||
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => measured(row, key) != null && Number.isFinite(measured(row, key)))
|
||||
? pallets.reduce((total, row) => total + measured(row, key)!, 0) : null
|
||||
const values = [
|
||||
{ label: '箱数', expected: forecast?.packageCount, actual: incoming?.receivedPackages },
|
||||
{ label: '实托数', expected: forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
|
||||
{ label: '标准托位(待核对)', expected: forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
|
||||
{ label: '箱数', expected: previous?.source.receivedPackages ?? forecast?.packageCount, actual: incoming?.receivedPackages },
|
||||
{ label: '实托数', expected: previous ? previous.source.pallets.length : forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
|
||||
{ label: '标准托位(待核对)', expected: previous ? previous.pallets.reduce((total, row) => total + row.palletSpaces, 0) : forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
|
||||
{ label: '重量(kg)', expected: forecast?.weightKg, actual: sum('weightKg') },
|
||||
{ label: '体积(m³)', expected: forecast?.volumeCbm, actual: sum('volumeCbm') },
|
||||
]
|
||||
return values.map(row => ({ ...row, difference: row.expected == null || row.actual == null ? null : Number((row.actual - row.expected).toFixed(3)) }))
|
||||
})
|
||||
const receiptChanged = computed(() => receiptComparison.value.some(row => row.difference != null && row.difference !== 0)
|
||||
|| unitPrice.value !== props.booking?.cargo.unitPrice
|
||||
|| !!props.booking?.receipt && (props.booking.receipt.source.receiptHash !== preview.value?.incoming?.receiptHash
|
||||
|| props.booking.receipt.pallets.some(row => spaces[row.warehousePalletId] !== row.palletSpaces)))
|
||||
const quantityText = (value: number | null | undefined) => value == null ? '—' : String(Number(value.toFixed(3)))
|
||||
const incomingStatusLabel = computed(() => {
|
||||
const status = preview.value?.incoming?.status
|
||||
@@ -49,15 +61,18 @@ const incomingStatusLabel = computed(() => {
|
||||
return statuses[status || ''] || '等待读取'
|
||||
})
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150, ...(props.mode === 'receipt' ? { slotName: 'weightKg', singleLine: false, showOverflowTooltip: false } : {}) }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150, ...(props.mode === 'receipt' ? { slotName: 'volumeCbm', singleLine: false, showOverflowTooltip: false } : {}) },
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 180, singleLine: false, showOverflowTooltip: false }] : [])])
|
||||
function resetMeasurements() {
|
||||
for (const field of ['weightKg', 'volumeCbm'] as const) Object.keys(measurements[field]).forEach(key => delete measurements[field][key])
|
||||
}
|
||||
async function load() {
|
||||
if (!props.booking) return
|
||||
const current = generation; loading.value = true; error.value = null
|
||||
try {
|
||||
const value = props.pool ? (await getInquiryFulfillment(props.booking.bookingNo)).preview : await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
|
||||
if (current !== generation) return
|
||||
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
preview.value = value; resetMeasurements(); Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
|
||||
await nextTick(); guard.capture()
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
|
||||
@@ -70,7 +85,7 @@ watch(() => [open.value, target.value], () => {
|
||||
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
|
||||
Object.assign(form, { estimatedArrivalAt: props.arrivalAt || '', deliveryInstructions: props.instructions || props.booking.cargo.remark || '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
if (props.initialPreview && (props.mode === 'receipt' || props.mode === 'view')) {
|
||||
preview.value = props.initialPreview; Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
preview.value = props.initialPreview; resetMeasurements(); Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
for (const pallet of props.initialPreview.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
|
||||
void nextTick(() => guard.capture())
|
||||
} else if (props.mode === 'receipt' || props.mode === 'view') void load()
|
||||
@@ -78,6 +93,7 @@ watch(() => [open.value, target.value], () => {
|
||||
}, { immediate: true })
|
||||
async function save() {
|
||||
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
|
||||
if (props.mode === 'receipt' && !receiptAllowed.value) return
|
||||
const current = generation, booking = props.booking, mode = props.mode
|
||||
if (!pending.value) {
|
||||
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
|
||||
@@ -88,8 +104,19 @@ async function save() {
|
||||
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
|
||||
} else {
|
||||
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
|
||||
if (preview.value.incoming.pallets.some(row => ['weightKg', 'volumeCbm'].some(key => {
|
||||
const field = key as 'weightKg' | 'volumeCbm', value = row[field] ?? measurements[field][row.warehousePalletId]
|
||||
return value == null || !Number.isFinite(value) || value <= 0
|
||||
}))) { ElMessage.warning('拆合托后的来源须重新填写该票货物在每托的实测重量和体积,不沿用旧托测量'); return }
|
||||
if (receiptChanged.value) {
|
||||
busy.value = true
|
||||
try { await ElMessageBox.confirm(`当前实托、货量、托位或约定价格存在变化,确认按当前完整实收和 ${amount.value?.toFixed(2)} ${booking.cargo.currency || ''} 核实?容量仍由服务端复核,超容量不会保存。`, '确认实收差异', { customClass: 'admin-density-confirm', confirmButtonText: '确认当前实收与运费', cancelButtonText: '返回核对', type: 'warning' }) }
|
||||
catch { return }
|
||||
finally { busy.value = false }
|
||||
if (current !== generation || !receiptAllowed.value) return
|
||||
}
|
||||
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
|
||||
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]! })) } }
|
||||
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]!, ...(row.weightKg == null ? { weightKg: measurements.weightKg[row.warehousePalletId]! } : {}), ...(row.volumeCbm == null ? { volumeCbm: measurements.volumeCbm[row.warehousePalletId]! } : {}) })) } }
|
||||
}
|
||||
}
|
||||
busy.value = true; error.value = null
|
||||
@@ -105,7 +132,7 @@ async function save() {
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (!pending && mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && (!receiptAllowed || (!pending && !ready)))" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="booking" v-loading="loading">
|
||||
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p><p v-if="pending" role="status">本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。</p>
|
||||
@@ -115,12 +142,13 @@ onBeforeUnmount(() => { generation++ })
|
||||
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
|
||||
</el-form>
|
||||
<template v-if="mode === 'receipt' || mode === 'view'">
|
||||
<p v-if="mode === 'receipt' && !receiptAllowed" role="alert">当前车次或预订不能核对实收,请刷新并确认车次仍在计划中、预订未绑定排车且具有核对实收权限。</p>
|
||||
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
|
||||
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template></DataTable>
|
||||
<section v-if="mode === 'receipt'" aria-label="预报与实收对照">
|
||||
<p>预报与实收对照;差异仅供核对,容量与费用仍需确认。</p>
|
||||
<p v-if="booking.status !== 'CONFIRMED'">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
|
||||
<table class="density-table"><thead><tr><th>项目</th><th>承运预报</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
|
||||
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template><template v-for="field in (['weightKg', 'volumeCbm'] as const)" #[field]="{ row }"><div v-if="row[field] == null" class="receipt-spaces-control"><el-input-number v-model="measurements[field][row.warehousePalletId]" :min="0.001" :max="999999999.999" :precision="3" :disabled="busy || !!pending" :aria-label="`${row.palletNo} ${field === 'weightKg' ? '实测重量' : '实测体积'}`" placeholder="重新实测该票货物" /></div><span v-else>{{ row[field] }}</span></template></DataTable>
|
||||
<p v-if="mode === 'receipt'">托盘组成变化后须填写该票外接货的当前实测重量、体积;未变化托盘只读沿用原收货证据,不填写整托混合货的总量。</p><section v-if="mode === 'receipt'" aria-label="预报与实收对照">
|
||||
<p>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实与当前实收对照' : '预报与实收对照' }};差异须明确确认,容量与费用仍由服务端核对。</p>
|
||||
<p v-if="booking.status !== 'CONFIRMED' && !booking.receipt">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
|
||||
<table class="density-table"><thead><tr><th>项目</th><th>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实' : '承运预报' }}</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
|
||||
<tbody><tr v-for="item in receiptComparison" :key="item.label"><td>{{ item.label }}</td><td>{{ quantityText(item.expected) }}</td><td>{{ quantityText(item.actual) }}</td><td>{{ item.difference == null ? '—' : item.difference > 0 ? `+${quantityText(item.difference)}` : quantityText(item.difference) }}</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ElInput } from 'element-plus/es/components/input/index'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingPanel from './TripBookingPanel.vue'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking } from '../bookingTypes'
|
||||
@@ -147,3 +148,30 @@ it('keeps manual entry available when candidate lookup fails and hides lookup wi
|
||||
const denied = mount(); await flushPromises(); await button(denied, '编辑').trigger('click'); await flushPromises()
|
||||
expect(denied.findAll('button').some(node => node.text() === '查询报价库')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['CONFIRMED', 'RECEIVED'] as const)('opens the existing receipt confirmation for an unbound %s booking', async status => {
|
||||
const current = { ...row, status, incomingNo: 'WT-1', recordVersion: 7 }
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 9, records: [current] })
|
||||
const wrapper = mount(); await flushPromises()
|
||||
await button(wrapper, '核对实收').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(TripBookingFulfillmentDialog).props()).toMatchObject({ modelValue: true, mode: 'receipt', booking: current })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ status: 'BOUND' as const, dispatchNo: 'DS-1', planned: true, permitted: true },
|
||||
{ status: 'RECEIVED' as const, dispatchNo: 'DS-1', planned: true, permitted: true },
|
||||
{ status: 'RECEIVED' as const, dispatchNo: null, planned: false, permitted: true },
|
||||
{ status: 'RECEIVED' as const, dispatchNo: null, planned: true, permitted: false },
|
||||
{ status: 'CANCELLED' as const, dispatchNo: null, planned: true, permitted: true },
|
||||
])('blocks receipt rechecking for $status / bound=$dispatchNo / planned=$planned / permitted=$permitted', async scenario => {
|
||||
access.writable = scenario.permitted
|
||||
const current = { ...row, status: scenario.status, incomingNo: 'WT-1', dispatchNo: scenario.dispatchNo }
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [current] })
|
||||
const wrapper = mount()
|
||||
if (!scenario.planned) await wrapper.setProps({ trip: { ...trip, status: 'IN_TRANSIT' } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('button').some(node => node.text() === '核对实收')).toBe(false)
|
||||
const state = wrapper.vm as unknown as { fulfill: (booking: TripBooking, mode: 'receipt') => void }
|
||||
state.fulfill(current, 'receipt'); await flushPromises()
|
||||
expect(wrapper.findComponent(TripBookingFulfillmentDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -18,7 +18,13 @@ const emit = defineEmits<{ changed: [version: number]; linked: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const records = ref<TripBooking[]>([]), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const fulfillmentOpen = ref(false), fulfillmentBooking = ref<TripBooking | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') { fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true }
|
||||
function canConfirmReceipt(row: TripBooking) {
|
||||
return planned.value && ['CONFIRMED', 'RECEIVED'].includes(row.status) && !!row.incomingNo && !row.dispatchNo && access.can(permission.tripBookingConfirm)
|
||||
}
|
||||
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') {
|
||||
if (busy.value || mode === 'receipt' && !canConfirmReceipt(row)) return
|
||||
fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true
|
||||
}
|
||||
function fulfilled(value: TripBookingResult) { accept(value); if (fulfillmentMode.value === 'bind') { emit('linked'); void load() } }
|
||||
const readOnly = ref(false)
|
||||
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
|
||||
@@ -145,7 +151,7 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
|
||||
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
|
||||
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="canConfirmReceipt(row as TripBooking)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
|
||||
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
|
||||
|
||||
@@ -45,13 +45,33 @@ describe('delivery order API', () => {
|
||||
})
|
||||
|
||||
it('loads return options and marks a return in the explicit customer scope', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] })
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ lastMileOrderId: '109', lastMileOrderNo: 'LM-1', inboundOrderId: '9', inboundCode: 'IN-1', returnSources: [] }] })
|
||||
await listDeliveryReturnInboundOptions('DEL/1', '1001')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
|
||||
|
||||
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
|
||||
const payload = { lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
|
||||
await markDeliveryReturn('DEL/1', '1001', payload)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
|
||||
})
|
||||
|
||||
it('preserves original return carton identity and submits it without inventing an inventory position', async () => {
|
||||
const source = { outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-CARTON-1', maxReturnPackages: 1 }
|
||||
const relation = { lastMileOrderId: '9007199254741299', lastMileOrderNo: 'LM-RETURN-1', inboundOrderId: null, inboundCode: null, customerId: '1001', returnSources: [{ ...source, maxReturnPackages: '1' }] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [relation] })
|
||||
const options = await listDeliveryReturnInboundOptions('DEL/1', '1001')
|
||||
expect(options[0]?.returnSources[0]).toEqual(source)
|
||||
expect(options[0]).toMatchObject({ lastMileOrderId: relation.lastMileOrderId, inboundOrderId: null, inboundCode: null })
|
||||
const payload = { lastMileOrderId: relation.lastMileOrderId, reason: '原箱拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: source.outboundPalletItemId, returnCartonId: source.returnCartonId, packageCount: 1 }], idempotencyKey: 'carton-return' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX-1' } })
|
||||
await markDeliveryReturn('DEL/1', '1001', payload)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[1]).not.toHaveProperty('inboundOrderId')
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', '0', 'invalid'])('rejects a candidate with invalid master identity %s even if it has an inbound association', async lastMileOrderId => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ lastMileOrderId, inboundOrderId: '9', inboundCode: 'IN-1', returnSources: [] }] })
|
||||
await expect(listDeliveryReturnInboundOptions('DEL/1', '1001')).rejects.toThrow('尾程主单标识')
|
||||
expect(http.post).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeReturnSource, type ReturnSourceIdentity } from '../returnSource'
|
||||
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
import type { DeliveryDocumentSummary } from './deliveryDocuments'
|
||||
@@ -70,26 +71,27 @@ export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: str
|
||||
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
|
||||
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[]; feeCoverage?: import('../feeCoverageTypes').FeeCoverageEvidence | null }
|
||||
|
||||
export interface DeliveryReturnSource {
|
||||
export interface DeliveryReturnSource extends ReturnSourceIdentity {
|
||||
outboundPalletItemId: string
|
||||
inventoryPositionId: string
|
||||
cartonMark?: string | null
|
||||
palletNo?: string | null
|
||||
skuCode?: string | null
|
||||
maxReturnPackages: number
|
||||
}
|
||||
export interface DeliveryReturnRelation {
|
||||
inboundOrderId: string
|
||||
lastMileOrderId: string
|
||||
inboundOrderId?: string | null
|
||||
inboundCode?: string | null
|
||||
lastMileOrderNo?: string | null
|
||||
customerId: string
|
||||
returnSources: DeliveryReturnSource[]
|
||||
}
|
||||
export interface MarkDeliveryReturnPayload {
|
||||
inboundOrderId: string
|
||||
lastMileOrderId: string
|
||||
reason: string
|
||||
returnDirection: 'OVERSEAS_WAREHOUSE' | 'CUSTOMER' | 'OTHER'
|
||||
returnLogisticsNo?: string
|
||||
items: Array<{ outboundPalletItemId: string; inventoryPositionId: string; packageCount: number }>
|
||||
items: Array<ReturnSourceIdentity & { outboundPalletItemId: string; packageCount: number }>
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
@@ -120,7 +122,14 @@ export async function listDeliveryReturnInboundOptions(deliveryNo: string, custo
|
||||
const response = await http.get<ApiEnvelope<DeliveryReturnRelation[]>, ApiEnvelope<DeliveryReturnRelation[]>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
return unwrapApiResponse(response).map(relation => {
|
||||
if (typeof relation.lastMileOrderId !== 'string' || !/^[1-9]\d*$/.test(relation.lastMileOrderId)) throw new Error('退回候选缺少有效尾程主单标识,请重新加载')
|
||||
return { ...relation, returnSources: relation.returnSources.map(source => {
|
||||
const quantity: unknown = source.maxReturnPackages
|
||||
if ((typeof quantity !== 'number' && typeof quantity !== 'string') || !/^\d+$/.test(String(quantity)) || !Number.isSafeInteger(Number(quantity))) throw new Error('可退箱数无效,请重新加载')
|
||||
return { ...normalizeReturnSource(source), maxReturnPackages: Number(quantity) }
|
||||
}) }
|
||||
})
|
||||
}
|
||||
|
||||
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: MarkDeliveryReturnPayload) {
|
||||
|
||||
@@ -14,6 +14,18 @@ export interface DispatchQuery {
|
||||
|
||||
export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
|
||||
export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
|
||||
export interface DispatchActions {
|
||||
canEdit: boolean
|
||||
editBlockReason: string | null
|
||||
canCancel: boolean
|
||||
cancelBlockReason: string | null
|
||||
canRemovePallet: boolean
|
||||
removePalletBlockReason: string | null
|
||||
canTransfer: boolean
|
||||
transferBlockReason: string | null
|
||||
outboundNo: string | null
|
||||
outboundStatus: string | null
|
||||
}
|
||||
|
||||
export interface DispatchPalletCandidateDto {
|
||||
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
|
||||
@@ -99,6 +111,7 @@ export async function getBatchDispatchCandidates(batchKey: string) {
|
||||
|
||||
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }> }
|
||||
export interface DispatchSummaryDto {
|
||||
actions?: DispatchActions
|
||||
appointmentBatches: DispatchBatchReference[]
|
||||
workflow?: DispatchWorkflow | null
|
||||
id: string
|
||||
|
||||
@@ -5,6 +5,13 @@ import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, ge
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('exception order API', () => {
|
||||
it('normalizes native carton sources and rejects missing, ambiguous or unsafe source identities', () => {
|
||||
const raw = { order: { version: '1' }, returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: '1' }], returnReceipts: [] } as unknown as ExceptionDetail
|
||||
expect(normalizeExceptionDetail(raw).returnSources[0]).toMatchObject({ inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: 1 })
|
||||
for (const identity of [{ inventoryPositionId: null, returnCartonId: null }, { inventoryPositionId: '20', returnCartonId: '21' }, { inventoryPositionId: 'null', returnCartonId: null }, { inventoryPositionId: null, returnCartonId: Number.MAX_SAFE_INTEGER + 2 }]) {
|
||||
expect(() => normalizeExceptionDetail({ ...raw, returnSources: [{ ...raw.returnSources[0]!, ...identity }] } as ExceptionDetail)).toThrow()
|
||||
}
|
||||
})
|
||||
it('preserves receipt and dispatch business numbers while normalizing all actual history quantities', () => {
|
||||
const raw = { order: { version: '2' }, returnSources: [], returnReceipts: [{ receiptNo: 'RSR-9007199254740993', receivedPackages: '2', receivedAt: '2026-10-03T23:24:13',
|
||||
redispatches: [{ dispatchNo: 'DS/1', status: 'DEPARTED', selectedPackages: '2', pickedPackages: '2', deliveries: [{ deliveryNo: 'DL/1', status: 'DELIVERED' }] }] }] } as unknown as ExceptionDetail
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeReturnSource, type ReturnSourceIdentity } from '../returnSource'
|
||||
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
@@ -13,12 +14,12 @@ export interface ExceptionOrderRow {
|
||||
version?: number; allowedActions?: string[]; evidenceFileIds?: string[]; handlingResult?: string | null; completedAt?: string | null
|
||||
}
|
||||
|
||||
export interface ExceptionReturnSource { outboundPalletItemId: string; inventoryPositionId: string; palletNo?: string | null; skuCode?: string | null; maxReturnPackages: number }
|
||||
export interface ExceptionReturnSource extends ReturnSourceIdentity { outboundPalletItemId: string; palletNo?: string | null; skuCode?: string | null; cartonMark?: string | null; maxReturnPackages: number }
|
||||
export interface ExceptionRelatedDelivery { deliveryNo: string; status: string }
|
||||
export interface ExceptionRedispatch { dispatchNo: string; status: string; selectedPackages: number; pickedPackages: number; deliveries: ExceptionRelatedDelivery[] }
|
||||
export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: number; receivedAt: string; redispatches: ExceptionRedispatch[] }
|
||||
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[] }
|
||||
export interface ExceptionReceipt { outboundPalletItemId: string; inventoryPositionId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
|
||||
export interface ExceptionReceipt extends ReturnSourceIdentity { outboundPalletItemId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
|
||||
export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] }
|
||||
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel'
|
||||
export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean }
|
||||
@@ -29,7 +30,7 @@ function safeInteger(value: unknown, label: string) {
|
||||
}
|
||||
export function normalizeExceptionDetail(value: ExceptionDetail): ExceptionDetail {
|
||||
return { ...value, order: { ...value.order, version: safeInteger(value.order.version, '异常版本'), evidenceFileIds: value.order.evidenceFileIds?.map(String) },
|
||||
returnSources: value.returnSources.map(row => ({ ...row, outboundPalletItemId: String(row.outboundPalletItemId), inventoryPositionId: String(row.inventoryPositionId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })),
|
||||
returnSources: value.returnSources.map(row => ({ ...normalizeReturnSource(row), outboundPalletItemId: String(row.outboundPalletItemId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })),
|
||||
returnReceipts: value.returnReceipts.map(receipt => ({ ...receipt, receivedPackages: safeInteger(receipt.receivedPackages, '历史实收数量'),
|
||||
redispatches: receipt.redispatches.map(dispatch => ({ ...dispatch, selectedPackages: safeInteger(dispatch.selectedPackages, '重派选定数量'),
|
||||
pickedPackages: safeInteger(dispatch.pickedPackages, '重派实拣数量') })) })) }
|
||||
|
||||
@@ -125,6 +125,22 @@ it('submits two actual batches using refreshed remaining sources and independent
|
||||
expect(second[3].idempotencyKey).not.toBe(first[3].idempotencyKey); expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('receives the native original carton whole into a verified target without inventing inventory', async () => {
|
||||
const carton = snapshot('EX-1', 1, 1)
|
||||
carton.returnSources = [{ outboundPalletItemId: '10', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }]
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(carton).mockResolvedValue(snapshot('EX-1', 2, 0))
|
||||
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 1, replayed: false })
|
||||
const wrapper = open(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('原退货箱 RETURN-CARTON-1(须整箱实收)')
|
||||
expect(wrapper.text()).not.toContain('9007199254740997')
|
||||
await fill(wrapper, 0.5); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(commandException).not.toHaveBeenCalled()
|
||||
await fill(wrapper, 1); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(commandException).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 'complete', expect.objectContaining({ receipts: [{ outboundPalletItemId: '10', returnCartonId: '9007199254740997', receivedPackages: 1, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' }] }))
|
||||
expect(getOverseasLocation).toHaveBeenCalledWith('92', '40')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getPallet, listPallets, type PalletDto } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { returnSourceKey, returnSourceReference } from '../returnSource'
|
||||
|
||||
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
@@ -105,12 +106,15 @@ async function execute(action: ExceptionAction) {
|
||||
if (action === 'complete' && warehouseReturn.value && (!selected.length || selected.some(row => !Number.isSafeInteger(row.quantity) || Number(row.quantity) > row.maxReturnPackages || !row.palletNo || !row.locationId || !row.locationCode))) {
|
||||
error.value = '请按剩余可收量填写本次实收,并选择已核对真实库位的目标托盘'; return
|
||||
}
|
||||
if (action === 'complete' && warehouseReturn.value && selected.some(row => row.returnCartonId && (row.quantity !== 1 || row.maxReturnPackages !== 1))) { error.value = '原退货箱必须按原箱整箱实收,请核对原箱及数量'; return }
|
||||
try { if (action === 'complete' && warehouseReturn.value) selected.forEach(returnSourceReference) }
|
||||
catch { error.value = '退回来源标识不完整或冲突,请重新读取详情'; return }
|
||||
const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() }
|
||||
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
|
||||
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
|
||||
if (action === 'complete') {
|
||||
payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles
|
||||
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, inventoryPositionId: row.inventoryPositionId, receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
|
||||
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, ...returnSourceReference(row), receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
|
||||
}
|
||||
pendingByScope.set(identity.value, { action, payload })
|
||||
}
|
||||
@@ -206,8 +210,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul>
|
||||
<template v-if="warehouseReturn">
|
||||
<p>只填写本批实际收到的箱数;未收齐保持处理中。目标库位取所选托盘的真实库位。</p>
|
||||
<div v-for="(row, index) in receipts" :key="`${row.outboundPalletItemId}:${row.inventoryPositionId}`">
|
||||
<p>{{ row.sourcePalletNo }} · SKU {{ row.skuCode || '—' }} · 剩余可收 {{ row.maxReturnPackages }} 箱</p>
|
||||
<div v-for="(row, index) in receipts" :key="returnSourceKey(row)">
|
||||
<p>{{ row.sourcePalletNo }} · <span v-if="row.returnCartonId">原退货箱 {{ row.cartonMark || '—' }}(须整箱实收)</span><span v-else>SKU {{ row.skuCode || '—' }}</span> · 剩余可收 {{ row.maxReturnPackages }} 箱</p>
|
||||
<!-- Element Plus initializes number-input ARIA on mount; remount when the form lock changes. -->
|
||||
<el-form-item label="本批实收箱数"><el-input-number :key="locked ? 'locked' : 'editable'" v-model="row.quantity" :data-test="`quantity-${index}`" :min="0" :max="row.maxReturnPackages" :precision="0" placeholder="未收到可留空" /></el-form-item>
|
||||
<el-form-item label="实际收货目标托盘"><el-select :model-value="row.target" :data-test="`target-${index}`" filterable remote :remote-method="searchTargets" placeholder="输入托盘码查询" @visible-change="onTargetVisibility" @update:model-value="onTargetChange(index, $event)"><el-option v-for="target in targets" :key="target.id" :value="String(target.id)" :label="target.palletCode" /></el-select></el-form-item>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
||||
import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, unlockDispatchDraftPallet, deleteDispatchDraftContainer, type DispatchPalletCandidateDto, type DispatchDraftDto } from '../api/dispatches'
|
||||
import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, lockDispatchDraftPallet, unlockDispatchDraftPallet, releaseDispatchDraft, deleteDispatchDraftContainer, type DispatchPalletCandidateDto, type DispatchDraftDto } from '../api/dispatches'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('../api/dispatches', () => ({
|
||||
listDispatchCandidates: vi.fn(), listDispatchDrafts: vi.fn(), preArrangeDispatch: vi.fn(), preArrangeDraftDispatch: vi.fn(),
|
||||
createDispatchDraftContainer: vi.fn(), deleteDispatchDraftContainer: vi.fn(),
|
||||
lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(),
|
||||
releaseDispatchDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
const shell = defineComponent({ setup(_, { slots }) { return () => h('section', [slots.default?.(), slots.footer?.()]) } })
|
||||
@@ -31,12 +32,12 @@ function mountDraft() {
|
||||
return shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: {
|
||||
stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true,
|
||||
ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true,
|
||||
ElTag: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
|
||||
ElTag: shell, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
|
||||
ElEmpty: true, ElRadio: true, ElRadioGroup: true },
|
||||
} })
|
||||
}
|
||||
const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', palletNo: 'PAL-C' }
|
||||
const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', palletNo: 'PAL-D' }
|
||||
const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', warehousePalletId: '103', palletNo: 'PAL-C' }
|
||||
const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', warehousePalletId: '104', palletNo: 'PAL-D' }
|
||||
const twoGroups: DispatchDraftDto = { draftToken: 'original-draft', containers: [
|
||||
{ id: 'container-1', capacity: 30, palletIds: ['11', '13'] },
|
||||
{ id: 'container-2', capacity: 30, palletIds: ['14', 'RETURN:22'] },
|
||||
@@ -181,7 +182,7 @@ it.each([
|
||||
it('rejects a relabel order split across multiple containers before either API command', async () => {
|
||||
const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
|
||||
{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c2', capacity: 30, palletIds: ['13'] },
|
||||
{ id: 'c1', capacity: 30, palletIds: ['101'] }, { id: 'c2', capacity: 30, palletIds: ['103'] },
|
||||
] }])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...secondPlan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
|
||||
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
|
||||
@@ -194,12 +195,12 @@ it('rejects a relabel order split across multiple containers before either API c
|
||||
|
||||
it('keeps one complete relabel order on the single-dispatch endpoint', async () => {
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
|
||||
{ id: 'c1', capacity: 30, palletIds: ['11', '13'] },
|
||||
{ id: 'c1', capacity: 30, palletIds: ['101', '103'] },
|
||||
] }])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan].map(p => ({ ...p, sourceStage: 'RELABEL', orderRefs: 'RL-1' })))
|
||||
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-RL', appointmentBatches: [] })
|
||||
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
|
||||
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['11', '13'], returnReceiptIds: [], relabelNo: 'RL-1' })
|
||||
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101', '103'], returnReceiptIds: [], relabelNo: 'RL-1' })
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
@@ -237,3 +238,101 @@ it('retains every restored selection on candidate failure and submits both sourc
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows a draft recovery error and only loads candidates after a successful retry with the original token', async () => {
|
||||
vi.mocked(listDispatchDrafts).mockRejectedValueOnce(new Error('草稿查询超时')).mockResolvedValueOnce([twoGroups])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
|
||||
const wrapper = mountDraft(); await flushPromises()
|
||||
expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
|
||||
expect(listDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
|
||||
await submitDraft(wrapper)
|
||||
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps local relabel choices on close and reopen while describing their actual unlocked state', async () => {
|
||||
const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel])
|
||||
const wrapper = mountDraft(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
|
||||
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('已选择,尚未锁定')
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
|
||||
expect(wrapper.text()).toContain('已选择,尚未锁定')
|
||||
expect(listDispatchDrafts).toHaveBeenCalledTimes(2)
|
||||
await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click'); await flushPromises()
|
||||
expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['RELABEL_APPLICATION', 'RETURN_RELABEL'])('rejects a partial %s application after refreshing its complete candidate set', async orderType => {
|
||||
const relabel = { ...plan, sourceStage: 'RELABEL' as const, orderRefs: 'RL-1', orderType }
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: ['101'] }] }])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...relabel, planPalletId: '13', warehousePalletId: '103' }])
|
||||
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
|
||||
expect(listDispatchCandidates).toHaveBeenLastCalledWith({ sourceStage: 'RELABEL', orderNo: 'RL-1', draftToken: 'local-relabel' })
|
||||
expect(preArrangeDispatch).not.toHaveBeenCalled()
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')?.message).toContain('全部应排托盘')
|
||||
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
|
||||
expect(wrapper.emitted('created')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves all previous selections when reopening fails to read drafts, then recovers the same groups on retry', async () => {
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockRejectedValueOnce(new Error('查询超时')).mockResolvedValueOnce([twoGroups])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
|
||||
const wrapper = mountDraft(); await flushPromises()
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
|
||||
expect(listDispatchCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '预排车')!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
|
||||
await submitDraft(wrapper)
|
||||
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('requires explicit release to restart an expired draft and keeps choices when release fails', async () => {
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockResolvedValue([])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
|
||||
vi.mocked(releaseDispatchDraft).mockRejectedValueOnce(new Error('释放超时')).mockResolvedValueOnce(null)
|
||||
const wrapper = mountDraft(); await flushPromises()
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原草稿已失效')
|
||||
expect(releaseDispatchDraft).not.toHaveBeenCalled()
|
||||
const restart = () => wrapper.findAll('button').find(button => button.text() === '释放草稿并重新选择')!
|
||||
await restart().trigger('click'); await flushPromises()
|
||||
expect(releaseDispatchDraft).toHaveBeenLastCalledWith('original-draft')
|
||||
expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原选择已保留')
|
||||
await restart().trigger('click'); await flushPromises()
|
||||
expect(releaseDispatchDraft).toHaveBeenNthCalledWith(2, 'original-draft')
|
||||
expect(wrapper.find('[data-test="retry"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([plan, returned])('does not claim a failed source lock succeeded for $sourceType', async candidate => {
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'lock-draft', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([candidate])
|
||||
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' } } }).mockResolvedValueOnce(null)
|
||||
const wrapper = mountDraft(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
|
||||
expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')).toMatchObject({ message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' })
|
||||
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
|
||||
expect(wrapper.text()).toContain('已锁定')
|
||||
expect(lockDispatchDraftPallet).toHaveBeenLastCalledWith(candidate.sourceType === 'RETURN_RECEIPT' ? 'RETURN:22' : '11', 'lock-draft', 'c1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -3,14 +3,14 @@ import { physicalWeightLb, totalPhysicalWeightLb } from '../dispatchPhysicalWeig
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { GripVertical, Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||||
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
|
||||
@@ -37,6 +37,10 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const draftError = ref<ApiErrorDetails | null>(null)
|
||||
const draftLoading = ref(false)
|
||||
const draftLoaded = ref(false)
|
||||
const draftBlocked = computed(() => draftLoading.value || !draftLoaded.value || Boolean(draftError.value))
|
||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const candidates = ref<DispatchPalletCandidateDto[]>([])
|
||||
const palletSnapshots = ref<Record<string, DispatchPalletCandidateDto>>({})
|
||||
@@ -73,7 +77,7 @@ const visibleCandidates = computed(() =>
|
||||
const orderGroups = computed(() => {
|
||||
const groups = new Map<string, DispatchPalletCandidateDto[]>()
|
||||
for (const item of visibleCandidates.value) {
|
||||
const key = String(item.orderId || item.orderNo || item.planId)
|
||||
const key = item.sourceStage === 'RELABEL' ? `RELABEL:${String(item.orderRefs || '')}` : String(item.orderId || item.orderNo || item.planId)
|
||||
groups.set(key, [...(groups.get(key) || []), item])
|
||||
}
|
||||
return [...groups.entries()].map(([key, pallets]) => ({
|
||||
@@ -99,6 +103,7 @@ const containerOverweight = (container: LoadingContainer) => {
|
||||
}
|
||||
|
||||
function palletId(item: DispatchPalletCandidateDto) {
|
||||
if (item.sourceStage === 'RELABEL') return String(item.warehousePalletId || '')
|
||||
if (item.sourceType === 'RETURN_RECEIPT') return item.returnReceiptId ? `RETURN:${item.returnReceiptId}` : ''
|
||||
return String(item.planPalletId || item.warehousePalletId || '')
|
||||
}
|
||||
@@ -166,6 +171,7 @@ async function hydrateDraftPallets() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (draftBlocked.value) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
@@ -198,12 +204,12 @@ function reset() {
|
||||
}
|
||||
|
||||
function addContainer() {
|
||||
if (saving.value) return
|
||||
if (saving.value || draftBlocked.value) return
|
||||
selectedVehicle.value = ''
|
||||
vehicleDialogOpen.value = true
|
||||
}
|
||||
async function confirmAddContainer() {
|
||||
if (saving.value) return
|
||||
if (saving.value || draftBlocked.value) return
|
||||
const spec = selectedVehicleSpec.value
|
||||
if (!spec) {
|
||||
ElMessage.warning('请选择车型')
|
||||
@@ -226,7 +232,7 @@ async function confirmAddContainer() {
|
||||
|
||||
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
|
||||
const id = palletId(item)
|
||||
if (saving.value || busyPalletIds.value.includes(id)) return
|
||||
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
|
||||
const target = containers.value.find((container) => container.id === containerId)
|
||||
if (!target || target.palletIds.includes(id)) return
|
||||
if (!String(item.overseasWarehouseId || '').trim()) {
|
||||
@@ -270,7 +276,7 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
|
||||
}
|
||||
|
||||
async function removePallet(id: string) {
|
||||
if (saving.value || busyPalletIds.value.includes(id)) return
|
||||
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
|
||||
const owner = containerFor(id)
|
||||
if (!owner) return
|
||||
busyPalletIds.value.push(id)
|
||||
@@ -286,13 +292,17 @@ async function removePallet(id: string) {
|
||||
}
|
||||
|
||||
async function deleteContainer(container: LoadingContainer) {
|
||||
if (saving.value) return
|
||||
if (saving.value || draftBlocked.value) return
|
||||
for (const id of [...container.palletIds]) {
|
||||
await removePallet(id)
|
||||
}
|
||||
if (!container.palletIds.length) {
|
||||
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
|
||||
containers.value = containers.value.filter((item) => item.id !== container.id)
|
||||
try {
|
||||
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
|
||||
containers.value = containers.value.filter((item) => item.id !== container.id)
|
||||
} catch (error) {
|
||||
mutationError.value = getApiErrorDetails(error, '删除装车车型失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) {
|
||||
@@ -311,11 +321,15 @@ async function close(value = false) {
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
if (draftBlocked.value) {
|
||||
ElMessage.warning('排车草稿尚未恢复,请先重试恢复草稿;原选择已保留')
|
||||
return
|
||||
}
|
||||
if (busyPalletIds.value.length) {
|
||||
ElMessage.warning('托盘来源正在锁定,请稍后再预排车')
|
||||
return
|
||||
}
|
||||
if (loading.value || unresolvedAssignedIds.value.length) {
|
||||
if (loading.value || loadError.value || unresolvedAssignedIds.value.length) {
|
||||
ElMessage.warning('草稿中部分已选来源尚未恢复,请重试加载后再预排;原选择已保留')
|
||||
return
|
||||
}
|
||||
@@ -358,9 +372,16 @@ async function submit() {
|
||||
mutationError.value = null
|
||||
try {
|
||||
if (relabelOrders.length) {
|
||||
const latest = await listDispatchCandidates({ sourceStage: 'RELABEL', orderNo: relabelOrders[0], draftToken: ensureDraftToken() })
|
||||
const required = latest.filter(item => item.sourceStage === 'RELABEL' && String(item.orderRefs || '').trim() === relabelOrders[0])
|
||||
const selected = new Set(assignedIds.value)
|
||||
if (!required.length || required.length !== selected.size || required.some(item => !selected.has(palletId(item)))) {
|
||||
mutationError.value = { message: '换标必须一次选择本申请全部应排托盘,并放入同一装车容器;候选可能已变化,请重新查询核对,原选择已保留' }
|
||||
return
|
||||
}
|
||||
await preArrangeDispatch({
|
||||
planPalletIds: assigned.value.filter(item => item.sourceType !== 'RETURN_RECEIPT').map(palletId),
|
||||
returnReceiptIds: assigned.value.filter(item => item.sourceType === 'RETURN_RECEIPT').map(item => String(item.returnReceiptId)),
|
||||
planPalletIds: assigned.value.map(palletId),
|
||||
returnReceiptIds: [],
|
||||
relabelNo: relabelOrders[0],
|
||||
})
|
||||
} else {
|
||||
@@ -385,6 +406,10 @@ async function submit() {
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
emit('created')
|
||||
draftLoaded.value = false
|
||||
draftToken.value = ''
|
||||
containers.value = []
|
||||
palletSnapshots.value = {}
|
||||
ElMessage.success('排车明细已同步到预排列表')
|
||||
await router.push({ name: 'overseas-dispatches' })
|
||||
} catch (error) {
|
||||
@@ -394,37 +419,72 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreDraft() {
|
||||
if (draftLoading.value) return
|
||||
draftLoading.value = true
|
||||
draftError.value = null
|
||||
try {
|
||||
const drafts = await listDispatchDrafts()
|
||||
const draft = draftToken.value ? drafts.find(item => item.draftToken === draftToken.value) : drafts[0]
|
||||
if (draftToken.value && containers.value.length && (!draft || containers.value.some(container =>
|
||||
container.palletIds.some(id => palletSnapshot(id)?.sourceStage === 'RELABEL') && !draft.containers?.some(item => item.id === container.id)))) {
|
||||
draftError.value = { message: '原草稿已失效或装车容器已变化,原选择已保留。请重试恢复;仍无法恢复时可释放草稿后重新选择。' }
|
||||
return
|
||||
}
|
||||
if (draft?.draftToken) {
|
||||
const restoredIds = new Set((draft.containers || []).flatMap(container => container.palletIds || []))
|
||||
if (assignedIds.value.some(id => palletSnapshot(id)?.sourceStage !== 'RELABEL' && !restoredIds.has(id))) {
|
||||
ElMessage.warning('原草稿部分来源锁已释放,已按服务端当前草稿恢复,请重新选择这些来源')
|
||||
}
|
||||
draftToken.value = draft.draftToken
|
||||
containers.value = (draft.containers || []).map((c) => {
|
||||
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
|
||||
const localRelabel = containers.value.find(item => item.id === c.id)?.palletIds.filter(id => palletSnapshot(id)?.sourceStage === 'RELABEL') || []
|
||||
return { id: c.id, capacity: c.capacity, maxWeightLb: spec.maxWeightLb, vehicleLabel: spec.label, palletIds: [...new Set([...(c.palletIds || []), ...localRelabel])] }
|
||||
})
|
||||
} else {
|
||||
ensureDraftToken()
|
||||
}
|
||||
draftLoaded.value = true
|
||||
} catch (error) {
|
||||
draftError.value = getApiErrorDetails(error, '排车草稿恢复失败,原选择已保留,请重试恢复后继续')
|
||||
} finally {
|
||||
draftLoading.value = false
|
||||
}
|
||||
if (draftLoaded.value && !draftError.value) await load()
|
||||
}
|
||||
|
||||
async function restartDraft() {
|
||||
if (draftLoading.value || saving.value || !draftToken.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm('释放原草稿的普通及退回来源锁,并清空本页换标选择后重新开始?', '重新选择排车来源', { customClass: 'admin-density-confirm', confirmButtonText: '释放并重新选择', cancelButtonText: '保留草稿', type: 'warning' })
|
||||
} catch { return }
|
||||
draftLoading.value = true
|
||||
try {
|
||||
await releaseDispatchDraft(draftToken.value)
|
||||
draftToken.value = ''
|
||||
containers.value = []
|
||||
palletSnapshots.value = {}
|
||||
draftLoaded.value = false
|
||||
draftError.value = null
|
||||
} catch (error) {
|
||||
draftError.value = getApiErrorDetails(error, '释放草稿失败,原选择已保留,请重试')
|
||||
} finally { draftLoading.value = false }
|
||||
if (!draftError.value) await restoreDraft()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
draftToken.value = makeDraftToken()
|
||||
containers.value = []
|
||||
palletSnapshots.value = {}
|
||||
query.orderNo = ''
|
||||
query.orderType = ''
|
||||
query.sourceStage = 'RECEIVED'
|
||||
query.fbaWarehouseCode = ''
|
||||
warehouseTab.value = 'ALL'
|
||||
mutationError.value = null
|
||||
void (async () => {
|
||||
const drafts = await listDispatchDrafts().catch(() => [])
|
||||
const draft = drafts[0]
|
||||
if (draft?.draftToken) {
|
||||
draftToken.value = draft.draftToken
|
||||
containers.value = (draft.containers || []).map((c) => {
|
||||
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
|
||||
return {
|
||||
id: c.id,
|
||||
capacity: c.capacity,
|
||||
maxWeightLb: spec.maxWeightLb,
|
||||
vehicleLabel: spec.label,
|
||||
palletIds: c.palletIds || [],
|
||||
}
|
||||
})
|
||||
}
|
||||
await load()
|
||||
})()
|
||||
draftLoaded.value = false
|
||||
void restoreDraft()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -445,7 +505,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
|
||||
</section>
|
||||
|
||||
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。沿用原入库库存和托盘,按本次换标箱唛及新目的仓排车,无需重新生成入库单。</p>
|
||||
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。必须一次选择本申请全部应排托盘并放入同一装车容器。换标选择仅保留在当前页面,尚未锁定;刷新页面后需重新选择,提交时由服务端重新校验。</p>
|
||||
|
||||
<nav class="warehouse-tabs density-tabs" aria-label="海外仓筛选">
|
||||
<button
|
||||
@@ -463,10 +523,12 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<LoadErrorState v-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
|
||||
<LoadErrorState v-if="draftError" title="排车草稿恢复失败" :message="draftError.message" :loading="draftLoading" @retry="restoreDraft"><template #details><ApiErrorMeta :details="draftError" /></template></LoadErrorState>
|
||||
<p v-else-if="draftLoading" role="status">正在恢复排车草稿,请稍候…</p>
|
||||
<LoadErrorState v-else-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
|
||||
><template #details><ApiErrorMeta :details="loadError" /></template
|
||||
></LoadErrorState>
|
||||
<div v-else class="dispatch-workbench">
|
||||
<div v-else v-loading="draftLoading" class="dispatch-workbench">
|
||||
<section class="order-pane" aria-label="可排车入仓单">
|
||||
<header>
|
||||
<div>
|
||||
@@ -549,7 +611,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag v-if="containerFor(palletId(item))" type="success" size="small">已锁定</el-tag>
|
||||
<el-tag v-if="containerFor(palletId(item))" :type="item.sourceStage === 'RELABEL' ? 'warning' : 'success'" size="small">{{ item.sourceStage === 'RELABEL' ? '已选择,尚未锁定' : '已锁定' }}</el-tag>
|
||||
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
|
||||
<el-button class="density-action" data-tone="primary" :loading="busyPalletIds.includes(palletId(item))">移入 </el-button>
|
||||
<template #dropdown
|
||||
@@ -599,7 +661,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span
|
||||
><el-button class="density-action" data-tone="danger" :disabled="saving" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
|
||||
</div>
|
||||
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
|
||||
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘;换标选择提交时校验,其他来源移入后锁定</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!containers.length" :image-size="64" description="请先添加装车托盘" />
|
||||
@@ -608,8 +670,8 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
</div>
|
||||
|
||||
<template #footer><footer class="dialog-footer">
|
||||
<span>托盘移入即锁定,重量按 1kg = 2.2046lb 换算。</span>
|
||||
<div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length || busyPalletIds.length > 0" @click="submit">预排车</el-button></div>
|
||||
<span>普通/退回来源移入后锁定;换标选择尚未锁定,仅保留在当前页面。重量按 1kg = 2.2046lb 换算。</span>
|
||||
<div><el-button v-if="draftError && draftToken" :disabled="draftLoading" @click="restartDraft">释放草稿并重新选择</el-button><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="draftBlocked || !assignedIds.length || busyPalletIds.length > 0" @click="submit">预排车</el-button></div>
|
||||
</footer></template>
|
||||
</AppDialog>
|
||||
<el-dialog class="admin-density-dialog" v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage } from 'element-plus'
|
||||
import { defineComponent, h, type PropType } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -10,7 +10,8 @@ import { getDeliveryDocuments, deliveryDocumentCategories } from '../api/deliver
|
||||
|
||||
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn(), listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() }))
|
||||
vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
|
||||
@@ -28,11 +29,12 @@ function mountView() {
|
||||
|
||||
describe('DeliveryOrdersView permissions and commands', () => {
|
||||
beforeEach(() => {
|
||||
routerPush.mockReset()
|
||||
canPermission.mockReset().mockReturnValue(false)
|
||||
vi.mocked(getDeliveryDocuments).mockReset()
|
||||
vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({ order: { ...row, version: 7 }, skus: [], fees: [], operations: [] })
|
||||
vi.mocked(listDeliveryOrders).mockReset().mockResolvedValue({ records: [row], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] }])
|
||||
vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ lastMileOrderId: '109', inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', returnCartonId: null, palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] }])
|
||||
vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
|
||||
vi.mocked(updateDeliveryOrderStatus).mockReset().mockResolvedValue({ deliveryNo: 'DEL/1', deliveryStatus: 'WAIT_DELIVERY', replayed: false })
|
||||
})
|
||||
@@ -68,7 +70,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', {
|
||||
inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE',
|
||||
lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE',
|
||||
returnLogisticsNo: undefined, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: expect.any(String),
|
||||
})
|
||||
wrapper.unmount()
|
||||
@@ -96,6 +98,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
expect(select.find('option[value="DELIVERING"]').text()).toBe('派送中')
|
||||
expect(select.find('option[value="DELIVERED"]').exists()).toBe(false)
|
||||
expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false)
|
||||
expect(select.find('option[value="CANCELLED"]').exists()).toBe(false)
|
||||
await select.setValue('DELIVERING')
|
||||
await wrapper.find('[data-dialog="编辑派送状态"] textarea').setValue('123')
|
||||
let reject!: (error: unknown) => void
|
||||
@@ -127,6 +130,110 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('removes status editing when cancellation is the only action and routes through the original outbound', async () => {
|
||||
canPermission.mockReturnValue(true)
|
||||
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, allowedActions: ['CANCEL_DELIVERY'] }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '编辑状态')).toBe(false)
|
||||
await wrapper.findAll('button').find(button => button.text() === '前往出库')!.trigger('click')
|
||||
expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-1' } })
|
||||
expect(updateDeliveryOrderStatus).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('offers no early delivery or rejection action when the server returns no executable action', async () => {
|
||||
canPermission.mockReturnValue(true)
|
||||
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, allowedActions: [] }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
expect(wrapper.findAll('button').some(button => ['编辑状态', '标记退货'].includes(button.text()))).toBe(false)
|
||||
expect(updateDeliveryOrderStatus).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('marks an original return carton as one whole box without submitting an inventory position', async () => {
|
||||
canPermission.mockReturnValue(true)
|
||||
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([{ lastMileOrderId: '9007199254741299', lastMileOrderNo: 'LM-RETURN-1', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }] }])
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.get('[data-dialog="标记退货"]')
|
||||
expect(dialog.text()).toContain('原退货箱 RETURN-CARTON-1(整箱退回)')
|
||||
expect(dialog.text()).toContain('LM-RETURN-1 · 无普通入仓关联')
|
||||
expect(dialog.text()).not.toContain('9007199254741299')
|
||||
expect(dialog.text()).not.toContain('9007199254740997')
|
||||
await dialog.get('textarea').setValue('原箱拒收')
|
||||
await dialog.get('input[type="checkbox"]').setValue(true)
|
||||
expect(dialog.get('input[type="number"]').attributes('disabled')).toBeDefined()
|
||||
await dialog.get('.dialog-confirm').trigger('click'); await flushPromises()
|
||||
expect(markDeliveryReturn).toHaveBeenCalledExactlyOnceWith('DEL/1', '100', expect.objectContaining({ lastMileOrderId: '9007199254741299', items: [{ outboundPalletItemId: '9007199254740995', returnCartonId: '9007199254740997', packageCount: 1 }] }))
|
||||
expect(vi.mocked(markDeliveryReturn).mock.calls[0]?.[2]).not.toHaveProperty('inboundOrderId')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('returns an ordinary relabel master with inventory sources without requiring an inbound record', async () => {
|
||||
canPermission.mockReturnValue(true)
|
||||
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([{ lastMileOrderId: '9007199254741300', lastMileOrderNo: 'LM-RELABEL-1', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', returnCartonId: null, palletNo: 'RELABEL-PALLET-1', skuCode: 'RELABEL-SKU', maxReturnPackages: 3 }] }])
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
try {
|
||||
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.get('[data-dialog="标记退货"]')
|
||||
expect(dialog.text()).toContain('LM-RELABEL-1 · 无普通入仓关联')
|
||||
expect(dialog.text()).not.toContain('9007199254741300')
|
||||
await dialog.get('textarea').setValue('换标主单拒收')
|
||||
await dialog.get('input[type="checkbox"]').setValue(true)
|
||||
await dialog.get('input[type="number"]').setValue(2)
|
||||
await dialog.get('.dialog-confirm').trigger('click'); await flushPromises()
|
||||
expect(markDeliveryReturn).toHaveBeenCalledExactlyOnceWith('DEL/1', '100', expect.objectContaining({ lastMileOrderId: '9007199254741300', items: [{ outboundPalletItemId: '11', inventoryPositionId: '12', packageCount: 2 }] }))
|
||||
expect(vi.mocked(markDeliveryReturn).mock.calls[0]?.[2]).not.toHaveProperty('inboundOrderId')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('keeps native cartons isolated when switching two masters that both have no inbound record', async () => {
|
||||
canPermission.mockReturnValue(true)
|
||||
const cartonA = { outboundPalletItemId: '11', inventoryPositionId: null, returnCartonId: '21', cartonMark: 'ORIGINAL-CARTON-A', maxReturnPackages: 1 }
|
||||
const cartonB = { ...cartonA, returnCartonId: '22', cartonMark: 'ORIGINAL-CARTON-B' }
|
||||
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([
|
||||
{ lastMileOrderId: '109', lastMileOrderNo: 'LM-RETURN-A', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [cartonA] },
|
||||
{ lastMileOrderId: '110', lastMileOrderNo: 'LM-RETURN-B', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [cartonB] },
|
||||
])
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
try {
|
||||
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.get('[data-dialog="标记退货"]')
|
||||
const masterSelect = dialog.get('select')
|
||||
expect(masterSelect.findAll('option').map(option => option.attributes('value'))).toEqual(['109', '110'])
|
||||
await masterSelect.setValue('109')
|
||||
await dialog.get('textarea').setValue('原箱拒收')
|
||||
await dialog.get('input[type="checkbox"]').setValue(true)
|
||||
await masterSelect.setValue('110')
|
||||
expect(dialog.text()).not.toContain('ORIGINAL-CARTON-A')
|
||||
expect(dialog.text()).toContain('ORIGINAL-CARTON-B')
|
||||
expect((dialog.get('input[type="checkbox"]').element as HTMLInputElement).checked).toBe(false)
|
||||
await dialog.get('.dialog-confirm').trigger('click'); await flushPromises()
|
||||
expect(markDeliveryReturn).not.toHaveBeenCalled()
|
||||
await dialog.get('input[type="checkbox"]').setValue(true)
|
||||
await dialog.get('.dialog-confirm').trigger('click'); await flushPromises()
|
||||
expect(markDeliveryReturn).toHaveBeenCalledExactlyOnceWith('DEL/1', '100', expect.objectContaining({ lastMileOrderId: '110', items: [{ outboundPalletItemId: '11', returnCartonId: '22', packageCount: 1 }] }))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('rejects a stale carton selection from another master before posting', async () => {
|
||||
canPermission.mockReturnValue(true)
|
||||
const ownCarton = { outboundPalletItemId: '11', inventoryPositionId: null, returnCartonId: '21', cartonMark: 'CURRENT-CARTON', maxReturnPackages: 1 }
|
||||
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([{ lastMileOrderId: '109', lastMileOrderNo: 'LM-RETURN-A', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [ownCarton] }])
|
||||
const warning = vi.spyOn(ElMessage, 'warning')
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
try {
|
||||
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.get('[data-dialog="标记退货"]')
|
||||
await dialog.get('textarea').setValue('拒收')
|
||||
const state = wrapper.vm as unknown as { returnItems: Array<typeof ownCarton & { selected: boolean; packageCount: number }> }
|
||||
state.returnItems = [{ ...ownCarton, returnCartonId: '22', cartonMark: 'FOREIGN-CARTON', selected: true, packageCount: 1 }]
|
||||
await dialog.get('.dialog-confirm').trigger('click'); await flushPromises()
|
||||
expect(markDeliveryReturn).not.toHaveBeenCalled()
|
||||
expect(warning).toHaveBeenCalledWith(expect.stringContaining('不属于当前尾程主单'))
|
||||
} finally { warning.mockRestore(); wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('validates selected return sources and reuses the idempotency key after a failed request', async () => {
|
||||
canPermission.mockImplementation(code => code.endsWith(':return'))
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
@@ -162,21 +269,21 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('clears the selected source when a different inbound order is chosen', async () => {
|
||||
it('clears the selected source when a different real master is chosen', async () => {
|
||||
canPermission.mockImplementation(code => code.endsWith(':return'))
|
||||
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([
|
||||
{ inboundOrderId: '9', inboundCode: 'IN-1', customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] },
|
||||
{ inboundOrderId: '10', inboundCode: 'IN-2', customerId: '100', returnSources: [] },
|
||||
{ lastMileOrderId: '109', lastMileOrderNo: 'LM-1', inboundOrderId: '9', inboundCode: 'IN-1', customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', returnCartonId: null, palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] },
|
||||
{ lastMileOrderId: '110', lastMileOrderNo: 'LM-2', inboundOrderId: '10', inboundCode: 'IN-2', customerId: '100', returnSources: [] },
|
||||
])
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
try {
|
||||
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.find('[data-dialog="标记退货"]')
|
||||
await dialog.find('select').setValue('9')
|
||||
await dialog.find('select').setValue('109')
|
||||
await dialog.find('input[type="checkbox"]').setValue(true)
|
||||
await dialog.find('input[type="number"]').setValue(2)
|
||||
await dialog.find('textarea').setValue('拒收')
|
||||
await dialog.find('select').setValue('10')
|
||||
await dialog.find('select').setValue('110')
|
||||
expect(dialog.find('input[type="checkbox"]').exists()).toBe(false)
|
||||
await dialog.find('.dialog-confirm').trigger('click')
|
||||
expect(markDeliveryReturn).not.toHaveBeenCalled()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,12 +7,15 @@ import { DataTableStub } from './phase5TestStubs'
|
||||
|
||||
const sourceQuery = vi.hoisted(() => ({} as Record<string, string>))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
const canPermission = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query: sourceQuery }), useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
|
||||
|
||||
describe('DispatchDetailView', () => {
|
||||
beforeEach(() => {
|
||||
routerPush.mockReset()
|
||||
canPermission.mockReset().mockReturnValue(false)
|
||||
Object.keys(sourceQuery).forEach(key => delete sourceQuery[key])
|
||||
vi.mocked(getDispatch).mockReset().mockResolvedValue({
|
||||
id: '1', dispatchNo: 'PC/1', appointmentBatches: [], vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
|
||||
@@ -42,6 +45,26 @@ describe('DispatchDetailView', () => {
|
||||
expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows server blocking reasons and opens only authorized original documents', async () => {
|
||||
const data = await getDispatch('PC/1')
|
||||
data.actions = { canEdit: false, editBlockReason: '请先解除车次关联', canCancel: false, cancelBlockReason: '请先解除车次关联', canRemovePallet: false, removePalletBlockReason: '请先解除车次关联', canTransfer: false, transferBlockReason: '原出库已取消,请取消原排车后重新预排', outboundNo: 'OUT-1', outboundStatus: 'CANCELLED' }
|
||||
data.workflow = { tripNo: 'TR-1', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
|
||||
vi.mocked(getDispatch).mockResolvedValue(data)
|
||||
canPermission.mockReturnValue(true)
|
||||
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('原出库已取消')
|
||||
await wrapper.findAll('button').find(button => button.text().includes('处理原车次'))!.trigger('click')
|
||||
expect(routerPush).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-1' }, query: { tab: 'overview' } })
|
||||
await wrapper.findAll('button').find(button => button.text().includes('查看原出库'))!.trigger('click')
|
||||
expect(routerPush).toHaveBeenLastCalledWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-1' } })
|
||||
wrapper.unmount()
|
||||
canPermission.mockReturnValue(false)
|
||||
const readonly = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } }); await flushPromises()
|
||||
expect(readonly.findAll('button').some(button => ['处理原车次', '查看原出库'].some(label => button.text().includes(label)))).toBe(false)
|
||||
expect(readonly.text()).toContain('原出库已取消')
|
||||
readonly.unmount()
|
||||
})
|
||||
it.each([false, true])('returns to the original inquiry only for a valid source: %s', async fromInquiry => {
|
||||
if (fromInquiry) Object.assign(sourceQuery, { fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' })
|
||||
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { physicalWeightKg } from '../dispatchPhysicalWeight'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -11,6 +12,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
interface DetailField { label: string; value: string | number }
|
||||
interface RelatedOrderRow {
|
||||
@@ -24,10 +26,24 @@ interface RelatedOrderRow {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
|
||||
const detail = ref<DispatchSummaryDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionBlockReasons = computed(() => {
|
||||
const actions = detail.value?.actions
|
||||
if (!actions) return detail.value ? ['操作资格暂未返回,请刷新详情'] : []
|
||||
return [...new Set([actions.editBlockReason, actions.cancelBlockReason, actions.removePalletBlockReason, actions.transferBlockReason].filter(Boolean))]
|
||||
})
|
||||
function openTrip() {
|
||||
if (!detail.value?.workflow || !access.can('platform:fleet:trip:list')) return
|
||||
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.value.workflow.tripNo }, query: { tab: 'overview' } })
|
||||
}
|
||||
function openOutbound() {
|
||||
if (!detail.value?.actions?.outboundNo || !access.can(permission.outboundList)) return
|
||||
void router.push({ name: 'overseas-outbounds', query: { documentNo: detail.value.actions.outboundNo } })
|
||||
}
|
||||
|
||||
const dispatchStatusLabels: Record<string, string> = {
|
||||
PENDING_DEPARTURE: '待发车',
|
||||
@@ -138,6 +154,7 @@ onMounted(load)
|
||||
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<section v-if="detail" class="detail-section" aria-label="排车操作提示"><p v-for="reason in actionBlockReasons" :key="String(reason)">{{ reason }}</p><div class="density-actions"><el-button v-if="detail.workflow && access.can('platform:fleet:trip:list')" class="density-action" @click="openTrip">处理原车次 {{ detail.workflow.tripNo }}</el-button><el-button v-if="detail.actions?.outboundNo && access.can(permission.outboundList)" class="density-action" @click="openOutbound">查看原出库 {{ detail.actions.outboundNo }}</el-button></div></section>
|
||||
<div class="summary-grid">
|
||||
<section class="detail-section" aria-labelledby="dispatch-vehicle-heading"><h2 id="dispatch-vehicle-heading">车辆信息</h2><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="detail-section" aria-labelledby="dispatch-info-heading"><h2 id="dispatch-info-heading">排车信息</h2><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus, { ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
|
||||
import ElementPlus, { ElMessage, ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -7,6 +7,7 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
|
||||
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
|
||||
import DispatchesView from './DispatchesView.vue'
|
||||
@@ -40,15 +41,19 @@ function memoryStorage(): Storage {
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
const allowedActions = { canEdit: true, editBlockReason: null, canCancel: true, cancelBlockReason: null,
|
||||
canRemovePallet: true, removePalletBlockReason: null, canTransfer: true, transferBlockReason: null, outboundNo: null, outboundStatus: null }
|
||||
|
||||
describe('DispatchesView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.mocked(listDispatches).mockReset().mockResolvedValue({
|
||||
records: [{
|
||||
id: '190000000000000099', dispatchNo: 'PC-1', appointmentBatches: [], vehiclePlate: 'CA-100', driverName: '张师傅',
|
||||
id: '190000000000000099', dispatchNo: 'PC-1', appointmentBatches: [], actions: allowedActions, vehiclePlate: 'CA-100', driverName: '张师傅',
|
||||
driverPhone: '555-0100', carrierName: 'Carrier A', orderNos: 'LM-1,LM-2', route: 'ONT8,LAX9',
|
||||
overseasWarehouseId: '321', overseasWarehouseName: '洛杉矶仓', departAt: '2026-08-20T09:00:00', fbaAppointmentNo: 'FBA-1',
|
||||
fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'BOOKED', estimatedArrivalAt: '2026-08-20T18:00:00',
|
||||
@@ -60,7 +65,7 @@ describe('DispatchesView', () => {
|
||||
vi.mocked(getDispatchBolPdf).mockReset().mockResolvedValue(new Blob(['%PDF-test'], { type: 'application/pdf' }))
|
||||
vi.mocked(removeDispatchItem).mockReset()
|
||||
vi.mocked(cancelDispatch).mockReset()
|
||||
vi.mocked(transferDispatchToDelivery).mockReset()
|
||||
vi.mocked(transferDispatchToDelivery).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', outboundNo: 'OUT-1', outboundStatus: 'WAIT_OUTBOUND', palletCount: 12, packageCount: 80, deliveries: [], replayed: false })
|
||||
vi.mocked(getDispatchExternalFreight).mockReset().mockResolvedValue({ snapshotHash: 'own-hash', requiresDeliveryFee: true, quotes: [] })
|
||||
vi.mocked(getDispatchFeeCoverage).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'cargo-hash', cargo: [], feeSources: [] })
|
||||
canPermission.mockReset().mockReturnValue(true)
|
||||
@@ -248,6 +253,59 @@ describe('DispatchesView', () => {
|
||||
expect(source).toContain('请确认海外尾程排车接口已发布')
|
||||
})
|
||||
|
||||
it('uses server blocks even for a pending row and offers the original trip and outbound paths', async () => {
|
||||
const result = await listDispatches({ page: 1, size: 10 })
|
||||
Object.assign(result.records[0]!, {
|
||||
actions: { ...allowedActions, canEdit: false, canCancel: false, canRemovePallet: false, canTransfer: false,
|
||||
editBlockReason: '请先在车次 TR-A 中解除关联', cancelBlockReason: '请先在车次 TR-A 中解除关联',
|
||||
removePalletBlockReason: '请先在车次 TR-A 中解除关联', transferBlockReason: '原出库已取消,请取消原排车后通过新预排重新安排',
|
||||
outboundNo: 'OUT-A', outboundStatus: 'CANCELLED' },
|
||||
workflow: { tripNo: 'TR-A', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } },
|
||||
})
|
||||
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
|
||||
const actions = wrapper.findAllComponents(RowActions).find(item => item.element.closest('.el-table__body-wrapper'))!
|
||||
const visible = actions.props('actions').filter(action => action.visible !== false).map(action => action.key)
|
||||
expect(visible.filter(key => ['edit', 'cancel', 'transfer'].includes(key))).toEqual([])
|
||||
expect(visible).toEqual(expect.arrayContaining(['trip', 'outbound', 'detail']))
|
||||
expect(wrapper.text()).toContain('原出库已取消')
|
||||
actions.vm.$emit('action', 'edit'); actions.vm.$emit('action', 'transfer'); await flushPromises()
|
||||
expect(wrapper.findAllComponents(AppDialog).filter(dialog => ['编辑排车', '转派送'].includes(String(dialog.props('title')))).every(dialog => !dialog.props('modelValue'))).toBe(true)
|
||||
actions.vm.$emit('action', 'trip'); await flushPromises()
|
||||
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } })
|
||||
actions.vm.$emit('action', 'outbound'); await flushPromises()
|
||||
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-A' } })
|
||||
expect(getDispatchExternalFreight).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps write commands closed when action capabilities are missing', async () => {
|
||||
const result = await listDispatches({ page: 1, size: 10 })
|
||||
delete result.records[0]!.actions
|
||||
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.findComponent(RowActions).props('actions').filter(action => action.visible !== false).map(action => action.key)).toEqual(['detail', 'bol'])
|
||||
expect(wrapper.text()).toContain('操作资格暂未返回,请刷新列表')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ replayed: true, outboundStatus: 'WAIT_OUTBOUND', method: 'info' as const, message: '本次未重复生成' },
|
||||
{ replayed: true, outboundStatus: 'CANCELLED', method: 'warning' as const, message: '请取消原排车后重新预排' },
|
||||
])('reports transfer replay accurately for $outboundStatus', async result => {
|
||||
const success = vi.spyOn(ElMessage, 'success'), notice = vi.spyOn(ElMessage, result.method)
|
||||
vi.mocked(transferDispatchToDelivery).mockResolvedValue({ dispatchNo: 'PC-1', outboundNo: 'OUT-OLD', palletCount: 1, packageCount: 2, deliveries: [], replayed: result.replayed, outboundStatus: result.outboundStatus })
|
||||
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [] })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
|
||||
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
|
||||
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
|
||||
}
|
||||
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(notice).toHaveBeenCalledWith(expect.stringContaining(result.message))
|
||||
expect(success).not.toHaveBeenCalledWith('已生成出库单和派送单')
|
||||
})
|
||||
|
||||
it('loads external quotes only when opened and submits their hash without a duplicate delivery charge', async () => {
|
||||
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [
|
||||
{ bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 },
|
||||
@@ -296,17 +354,18 @@ describe('DispatchesView', () => {
|
||||
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], feeCoverageCargoHash: 'actual-cargo', feeCoverage: [{ financeFeeId: '711', sourceVersion: 3, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }] }))
|
||||
})
|
||||
|
||||
it('gates edit, cancel and transfer with independent permissions and service statuses', async () => {
|
||||
it('gates edit, cancel, pallet removal and transfer with independent permissions and server capabilities', async () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
|
||||
expect(source).toContain('permission.dispatchEdit')
|
||||
expect(source).toContain('permission.dispatchCancel')
|
||||
expect(source).toContain('permission.dispatchTransferDelivery')
|
||||
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
|
||||
expect(source).toContain("{ key: 'transfer', label: '转派送', priority: 250")
|
||||
expect(source).toMatch(/function isPendingDeparture\(row: DispatchListItem\)\s*{\s*return row\.statusCode === 'PENDING_DEPARTURE'/)
|
||||
expect(source).toMatch(/access\.can\(permission\.dispatchEdit\)\s*&&\s*isPendingDeparture\(row\)/)
|
||||
expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/)
|
||||
expect(source).toContain('未生成出库单前可移除托盘')
|
||||
expect(source).toContain('access.can(permission.dispatchEdit) && row.actions?.canEdit === true')
|
||||
expect(source).toContain('access.can(permission.dispatchCancel) && row.actions?.canCancel === true')
|
||||
expect(source).toContain('access.can(permission.dispatchTransferDelivery) && row.actions?.canTransfer === true')
|
||||
expect(source).toContain('access.can(permission.dispatchEdit) && row.actions?.canRemovePallet === true')
|
||||
expect(source).toContain('!canRemovePallet(editRow.value)')
|
||||
expect(source).toContain('removeDispatchItem')
|
||||
expect(source).not.toContain('托盘箱唛确认')
|
||||
expect(source).not.toContain('palletCartonMarks')
|
||||
|
||||
@@ -87,6 +87,7 @@ const filters = ref<QueryModel>({
|
||||
const transferOpen = ref(false)
|
||||
const transferRow = ref<DispatchListItem | null>(null)
|
||||
const transferSaving = ref(false)
|
||||
const transferIdempotencyKey = ref('')
|
||||
const externalFreight = ref<ExternalFreightPreview | null>(null)
|
||||
const coveragePreview = ref<FeeCoveragePreview | null>(null)
|
||||
const coverageSelection = ref<FeeCoverageSelection[]>([])
|
||||
@@ -326,6 +327,7 @@ function appointmentLabel(numberValue: unknown, statusValue: unknown) {
|
||||
|
||||
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
|
||||
return {
|
||||
actions: dto.actions,
|
||||
workflow: dto.workflow,
|
||||
id: String(dto.id),
|
||||
dispatchNo: text(dto.dispatchNo),
|
||||
@@ -420,17 +422,26 @@ async function openBol(row: DispatchListItem) {
|
||||
bolLoadingNo.value = ''
|
||||
}
|
||||
}
|
||||
function isPendingDeparture(row: DispatchListItem) {
|
||||
return row.statusCode === 'PENDING_DEPARTURE'
|
||||
}
|
||||
function canEdit(row: DispatchListItem) {
|
||||
return access.can(permission.dispatchEdit) && isPendingDeparture(row)
|
||||
return access.can(permission.dispatchEdit) && row.actions?.canEdit === true
|
||||
}
|
||||
function canCancel(row: DispatchListItem) {
|
||||
return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE'
|
||||
return access.can(permission.dispatchCancel) && row.actions?.canCancel === true
|
||||
}
|
||||
function canTransfer(row: DispatchListItem) {
|
||||
return access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode)
|
||||
return access.can(permission.dispatchTransferDelivery) && row.actions?.canTransfer === true
|
||||
}
|
||||
function canRemovePallet(row: DispatchListItem) {
|
||||
return access.can(permission.dispatchEdit) && row.actions?.canRemovePallet === true
|
||||
}
|
||||
function actionBlockReasons(row: DispatchListItem) {
|
||||
const actions = row.actions
|
||||
if (!actions) return ['操作资格暂未返回,请刷新列表']
|
||||
return [...new Set([actions.editBlockReason, actions.cancelBlockReason, actions.removePalletBlockReason, actions.transferBlockReason].filter(Boolean))]
|
||||
}
|
||||
function openOutbound(row: DispatchListItem) {
|
||||
if (!row.actions?.outboundNo || !access.can(permission.outboundList)) return
|
||||
void router.push({ name: 'overseas-outbounds', query: { documentNo: row.actions.outboundNo } })
|
||||
}
|
||||
function openEdit(row: DispatchListItem) {
|
||||
if (!canEdit(row)) return
|
||||
@@ -455,6 +466,7 @@ function openEdit(row: DispatchListItem) {
|
||||
async function openTransfer(row: DispatchListItem) {
|
||||
if (!canTransfer(row)) return
|
||||
transferRow.value = row
|
||||
transferIdempotencyKey.value = `web-transfer-${row.dispatchNo}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`
|
||||
Object.assign(transferForm, {
|
||||
plannedDeliveryAt: '',
|
||||
deliveryMethod: '',
|
||||
@@ -517,7 +529,7 @@ async function submitEdit() {
|
||||
}
|
||||
async function removePallet(itemValue: unknown) {
|
||||
const item = itemValue as DispatchItemDto
|
||||
if (!editRow.value || removingItemId.value) return
|
||||
if (!editRow.value || !canRemovePallet(editRow.value) || removingItemId.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认从排车单移除托盘 ${text(item.palletNo)}?移除后可重新排车。`, '移除排车托盘', { customClass: 'admin-density-confirm',
|
||||
confirmButtonText: '确认移除',
|
||||
@@ -536,10 +548,8 @@ async function removePallet(itemValue: unknown) {
|
||||
dispatchSnapshots.set(editRow.value.dispatchNo, updated)
|
||||
const row = rows.value.find((value) => value.dispatchNo === editRow.value?.dispatchNo)
|
||||
if (row) {
|
||||
row.palletCount = number(updated.palletCount)
|
||||
row.packageCount = number(updated.packageCount)
|
||||
row.orderNos = text(updated.orderNos)
|
||||
row.route = text(updated.route)
|
||||
Object.assign(row, toListItem(updated))
|
||||
editRow.value = row
|
||||
}
|
||||
ElMessage.success('托盘已移除,可重新排车')
|
||||
} catch (cause) {
|
||||
@@ -562,7 +572,7 @@ async function cancel(row: DispatchListItem) {
|
||||
mutationError.value = null
|
||||
try {
|
||||
await cancelDispatch(row.dispatchNo)
|
||||
ElMessage.success('排车单已取消')
|
||||
ElMessage.success('排车单已取消并释放占用,可从入仓单或换标单重新预排')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试')
|
||||
@@ -579,6 +589,8 @@ function rowActions(row: DispatchListItem): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '查看', priority: 300 },
|
||||
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
|
||||
{ key: 'trip', label: '处理原车次', priority: 150, visible: !!row.workflow && access.can('platform:fleet:trip:list') },
|
||||
{ key: 'outbound', label: '查看原出库', priority: 150, visible: !!row.actions?.outboundNo && access.can(permission.outboundList) },
|
||||
{ key: 'edit', label: '编辑', priority: 100, visible: canEdit(row) },
|
||||
{ key: 'transfer', label: '转派送', priority: 250, tone: 'success', visible: canTransfer(row) },
|
||||
{ key: 'cancel', label: '取消', tone: 'danger', divided: true, visible: canCancel(row) },
|
||||
@@ -588,6 +600,8 @@ function handleRowAction(command: string, row: DispatchListItem) {
|
||||
if (command === 'review') openTrip(row, section.value === 'dispatchReview' ? 'overview' : 'loading')
|
||||
else if (command === 'detail') openDetail(row)
|
||||
else if (command === 'bol') void openBol(row)
|
||||
else if (command === 'trip') openTrip(row, 'overview')
|
||||
else if (command === 'outbound') openOutbound(row)
|
||||
else if (command === 'edit') openEdit(row)
|
||||
else if (command === 'transfer') openTransfer(row)
|
||||
else if (command === 'cancel') void cancel(row)
|
||||
@@ -628,8 +642,8 @@ async function submitTransfer() {
|
||||
}))
|
||||
transferSaving.value = true
|
||||
try {
|
||||
await transferDispatchToDelivery(transferRow.value.dispatchNo, {
|
||||
idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`,
|
||||
const result = await transferDispatchToDelivery(transferRow.value.dispatchNo, {
|
||||
idempotencyKey: transferIdempotencyKey.value,
|
||||
plannedDeliveryAt: transferForm.plannedDeliveryAt,
|
||||
deliveryMethod: transferForm.deliveryMethod,
|
||||
waybillNo: transferForm.waybillNo.trim() || undefined,
|
||||
@@ -650,7 +664,13 @@ async function submitTransfer() {
|
||||
feeCoverage: coverageSelection.value,
|
||||
})
|
||||
transferOpen.value = false
|
||||
ElMessage.success('已生成出库单和派送单')
|
||||
if (result.outboundStatus === 'CANCELLED') {
|
||||
ElMessage.warning(`原出库单 ${result.outboundNo} 已取消,本次未生成新单据;请取消原排车后重新预排`)
|
||||
} else if (result.replayed) {
|
||||
ElMessage.info(`已存在出库单 ${result.outboundNo} 及关联派送单,本次未重复生成`)
|
||||
} else {
|
||||
ElMessage.success('已生成出库单和派送单')
|
||||
}
|
||||
await load()
|
||||
} catch (cause) {
|
||||
mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试')
|
||||
@@ -676,7 +696,7 @@ onMounted(load)
|
||||
</div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></div></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /><p v-for="reason in actionBlockReasons(row as DispatchListItem)" :key="String(reason)">{{ reason }}</p></template>
|
||||
<template #workflow="{ row }">
|
||||
<el-button v-if="row.workflow && access.can('platform:fleet:trip:list')" link type="primary" @click="openTrip(row as DispatchListItem, 'overview')">{{ row.workflow.tripNo }}</el-button>
|
||||
<span v-else>{{ row.workflow?.tripNo || '未关联车次' }}</span>
|
||||
@@ -695,7 +715,7 @@ onMounted(load)
|
||||
</DataTable>
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" :confirm-disabled="freightLoading || !externalFreight" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><DeliveryFeeCoveragePanel v-model="coverageSelection" :preview="coveragePreview" :loading="freightLoading" :disabled="transferSaving || freightLoading" /><p v-if="coveredServices.length">本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。</p><h3>本次新增费用</h3><p v-if="freightLoading">正在读取约定运费…</p><p v-for="quote in externalFreight?.quotes || []" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.incomingNo }}:{{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)</p><div class="transfer-grid"><el-form-item v-if="requiresDeliveryFee" :label="externalFreight?.quotes.length || coverageSelection.length ? '派送费(未覆盖部分,USD)' : '派送费用(USD)'" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="requiresLoadingFee" label="装车费(USD)" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="requiresAppointmentFee" label="买约费用(USD)" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="原运费仅按上方确认的服务范围引用;外接运费按实收约定带入。下方只登记未覆盖货物或服务的新增应收。" type="info" :closable="false" /></el-form></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;托盘移除资格以当前服务端校验结果为准。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><p v-if="editRow?.actions?.removePalletBlockReason">{{ editRow.actions.removePalletBlockReason }}</p><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId) || !editRow || !canRemovePallet(editRow)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.transfer-grid,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import OutboundsView from './OutboundsView.vue'
|
||||
const api = vi.hoisted(() => ({ create: vi.fn(), stock: vi.fn(), cartons: vi.fn(), detail: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户一' }], total: 1, pages: 1 }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn().mockResolvedValue({ records: [{ id: 'W1', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] }) }))
|
||||
vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }), listOneClickOutboundCandidates: vi.fn().mockResolvedValue([]), listReturnOutboundCandidates: vi.fn().mockResolvedValue([]), createReturnOutbound: api.create }))
|
||||
|
||||
@@ -7,7 +7,7 @@ import { listPlatformTenants } from '@/modules/iam'
|
||||
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), transfers: vi.fn(), bindDestination: vi.fn() }))
|
||||
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: api.warehouses }))
|
||||
vi.mock('../api/outbounds', () => ({
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listOutbounds } from '../api/outbounds'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cancelOutbound, listOutbounds } from '../api/outbounds'
|
||||
import OutboundsView from './OutboundsView.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
|
||||
const canPermission = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
const routeQuery = vi.hoisted(() => ({} as Record<string, string>))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: routeQuery }), useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn(), cancelOutbound: vi.fn(), createOneClickDropshipOutbound: vi.fn() }))
|
||||
vi.mock('../api/inventory', () => ({ listInventoryBalances: vi.fn(), listInventoryPositions: vi.fn() }))
|
||||
|
||||
class ResizeObserverStub { observe() {} unobserve() {} disconnect() {} }
|
||||
|
||||
describe('OutboundsView permissions and navigation', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
it('distinguishes outbound stages and does not present unknown states as picking', async () => {
|
||||
const statuses = [
|
||||
['WAIT_PICK', '待拣货', 'warning'], ['WAIT_OUTBOUND', '待出库', 'warning'], ['PICKING', '拣货中', 'info'],
|
||||
@@ -36,6 +38,8 @@ describe('OutboundsView permissions and navigation', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(routeQuery).forEach(key => delete routeQuery[key])
|
||||
vi.mocked(cancelOutbound).mockReset().mockResolvedValue(null)
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
|
||||
vi.mocked(listOutbounds).mockReset().mockResolvedValue({ records: [{ id: '1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'WAIT_PICK', palletCount: 1, packageCount: 2 }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
@@ -43,6 +47,27 @@ describe('OutboundsView permissions and navigation', () => {
|
||||
routerPush.mockReset()
|
||||
})
|
||||
|
||||
it('uses an original outbound deep link as the server query', async () => {
|
||||
routeQuery.documentNo = 'OUT/FROM-DELIVERY'
|
||||
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(listOutbounds).toHaveBeenCalledWith(expect.objectContaining({ documentNo: 'OUT/FROM-DELIVERY' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('explains the cancellation order and routes the cancelled outbound to its original dispatch', async () => {
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '重新排车', action: 'confirm' } as Awaited<ReturnType<typeof ElMessageBox.prompt>>)
|
||||
const success = vi.spyOn(ElMessage, 'success')
|
||||
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
vi.mocked(listOutbounds).mockResolvedValue({ records: [{ id: '1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'CANCELLED', palletCount: 1, packageCount: 2 }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
|
||||
expect(prompt).toHaveBeenCalledWith(expect.stringContaining('出库取消后再取消原排车'), '取消出库单', expect.any(Object))
|
||||
expect(cancelOutbound).toHaveBeenCalledExactlyOnceWith('OUT-1', '重新排车')
|
||||
expect(success).toHaveBeenCalledWith(expect.stringContaining('再从入仓单或换标单重新预排'))
|
||||
await wrapper.findAll('button').find(button => button.text() === '处理原排车')!.trigger('click')
|
||||
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-dispatches', query: { dispatchNo: 'PC-1' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses list access for detail and independent create/cancel permissions', async () => {
|
||||
canPermission.mockImplementation((code: string) => code.endsWith(':list'))
|
||||
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, Search } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -24,8 +24,13 @@ import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@
|
||||
import { getReturnCarton, listReturnCartons, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnStockRow } from '../api/returnInventory'
|
||||
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
function openDispatch(row: OutboundRow) {
|
||||
if (!row.dispatchNo || !access.can(permission.dispatchList)) return
|
||||
void router.push({ name: 'overseas-dispatches', query: { dispatchNo: row.dispatchNo } })
|
||||
}
|
||||
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ documentNo: '', lxCode: '', warehouseId: '', status: '', outboundType: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const cartonMarks = ref(''); const selectedReturnMarks = ref<string[]>([]); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const dropshipCandidates = ref<OneClickOutboundCandidate[]>([]); const selectedDropshipIds = ref<string[]>([]); const returnCandidates = ref<ReturnOutboundCandidate[]>([]); const selectedWarehouseId = ref(''); const selectedReturnNo = ref(''); const outboundType = ref<'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND' | 'WAREHOUSE_TRANSFER_OUTBOUND'>('ONE_CLICK_DROPSHIP'); const inventoryLoading = ref(false); const dropshipQueryInput = ref(''); const dropshipQuery = ref(''); const dropshipPage = ref(1); const dropshipPageSize = ref(5)
|
||||
const filters = ref<QueryModel>({ documentNo: typeof route.query.documentNo === 'string' ? route.query.documentNo : '', lxCode: '', warehouseId: '', status: '', outboundType: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const cartonMarks = ref(''); const selectedReturnMarks = ref<string[]>([]); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const dropshipCandidates = ref<OneClickOutboundCandidate[]>([]); const selectedDropshipIds = ref<string[]>([]); const returnCandidates = ref<ReturnOutboundCandidate[]>([]); const selectedWarehouseId = ref(''); const selectedReturnNo = ref(''); const outboundType = ref<'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND' | 'WAREHOUSE_TRANSFER_OUTBOUND'>('ONE_CLICK_DROPSHIP'); const inventoryLoading = ref(false); const dropshipQueryInput = ref(''); const dropshipQuery = ref(''); const dropshipPage = ref(1); const dropshipPageSize = ref(5)
|
||||
const tenantOptions = ref<PlatformTenantResource[]>([])
|
||||
const warehouseOptionsSource = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const fbaWarehouseOptionsSource = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
@@ -236,7 +241,27 @@ async function submit() {
|
||||
}
|
||||
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
|
||||
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
|
||||
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消;派送应收撤销结果请查看费用明细,排车占用需回排车列表取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
|
||||
async function cancel(row: OutboundRow) {
|
||||
if (!canCancel(row)) return
|
||||
let reason = ''
|
||||
const dispatchHint = row.dispatchNo ? '关联排车须在出库取消后再取消原排车,释放占用后才能重新预排。' : ''
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。${dispatchHint}`, '取消出库单', {
|
||||
customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/,
|
||||
inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning',
|
||||
})
|
||||
reason = result.value.trim()
|
||||
} catch { return }
|
||||
mutationError.value = null
|
||||
try {
|
||||
await cancelOutbound(row.outboundNo, reason)
|
||||
const nextStep = !row.dispatchNo ? '' : access.can(permission.dispatchList)
|
||||
? ',关联排车请点击“处理原排车”取消并释放占用,再从入仓单或换标单重新预排'
|
||||
: ',请由有排车权限的人员取消原排车并释放占用,再从入仓单或换标单重新预排'
|
||||
ElMessage.success(`出库单已取消;派送应收撤销结果请查看费用明细${nextStep}`)
|
||||
await load()
|
||||
} catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') }
|
||||
}
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
onMounted(load)
|
||||
@@ -253,7 +278,7 @@ onMounted(load)
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button><el-button v-if="row.dispatchNo && access.can(permission.dispatchList)" class="density-action" data-tone="primary" size="small" @click="openDispatch(row as OutboundRow)">{{ row.status === 'CANCELLED' ? '处理原排车' : '查看排车' }}</el-button></div></template>
|
||||
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
|
||||
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
|
||||
</DataTable></section>
|
||||
|
||||
@@ -257,11 +257,15 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
expect(dialog).toContain('listDispatchCandidates')
|
||||
expect(dialog).toContain('preArrangeDispatch')
|
||||
expect(dialog).toContain("planPalletIds: assigned.value.filter(item => item.sourceType !== 'RETURN_RECEIPT')")
|
||||
expect(dialog).toContain("returnReceiptIds: assigned.value.filter(item => item.sourceType === 'RETURN_RECEIPT')")
|
||||
expect(dialog).toContain("planPalletIds: sources.filter(item => item.sourceType !== 'RETURN_RECEIPT')")
|
||||
expect(dialog).toContain("returnReceiptIds: sources.filter(item => item.sourceType === 'RETURN_RECEIPT')")
|
||||
expect(dialog).toContain('planPalletIds: assigned.value.map(palletId)')
|
||||
expect(dialog).toContain("if (item.sourceStage === 'RELABEL') return String(item.warehousePalletId || '')")
|
||||
expect(dialog).toContain('lockDispatchDraftPallet')
|
||||
expect(dialog).toContain('unlockDispatchDraftPallet')
|
||||
expect(dialog).not.toContain('releaseDispatchDraft')
|
||||
expect(dialog.match(/async function close\([\s\S]*?\n}/)?.[0]).not.toContain('releaseDispatchDraft')
|
||||
expect(dialog).toContain('await releaseDispatchDraft(draftToken.value)')
|
||||
expect(dialog).toContain('释放并重新选择')
|
||||
expect(dialog).toContain('排车明细已同步到预排列表')
|
||||
expect(dialog).toContain("router.push({ name: 'overseas-dispatches' })")
|
||||
expect(dialog).not.toContain('customerId:')
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface ReturnSourceIdentity {
|
||||
inventoryPositionId?: string | null
|
||||
returnCartonId?: string | null
|
||||
}
|
||||
|
||||
function sourceId(value: unknown) {
|
||||
if (value == null) return null
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('退回来源标识无效,请重新加载')
|
||||
const id = String(value)
|
||||
if (!/^[1-9]\d*$/.test(id)) throw new Error('退回来源标识无效,请重新加载')
|
||||
return id
|
||||
}
|
||||
|
||||
export function returnSourceReference(source: ReturnSourceIdentity): { inventoryPositionId: string } | { returnCartonId: string } {
|
||||
const inventoryPositionId = sourceId(source.inventoryPositionId), returnCartonId = sourceId(source.returnCartonId)
|
||||
if (Boolean(inventoryPositionId) === Boolean(returnCartonId)) throw new Error('退回来源必须是库存仓位或原退货箱之一,请重新加载')
|
||||
return inventoryPositionId ? { inventoryPositionId } : { returnCartonId: returnCartonId! }
|
||||
}
|
||||
|
||||
export function normalizeReturnSource<T extends ReturnSourceIdentity>(source: T) {
|
||||
const reference = returnSourceReference(source)
|
||||
return { ...source, inventoryPositionId: 'inventoryPositionId' in reference ? reference.inventoryPositionId : null,
|
||||
returnCartonId: 'returnCartonId' in reference ? reference.returnCartonId : null }
|
||||
}
|
||||
|
||||
export function returnSourceKey(source: ReturnSourceIdentity & { outboundPalletItemId: string }) {
|
||||
return `${source.outboundPalletItemId}:${source.returnCartonId ? `CARTON:${source.returnCartonId}` : `POSITION:${source.inventoryPositionId}`}`
|
||||
}
|
||||
@@ -101,6 +101,7 @@ export interface InboundOrderListItem {
|
||||
}
|
||||
|
||||
export interface DispatchListItem {
|
||||
actions?: import('./api/dispatches').DispatchActions
|
||||
workflow?: import('./api/dispatches').DispatchWorkflow | null
|
||||
id: string
|
||||
dispatchNo: string
|
||||
|
||||
Reference in New Issue
Block a user