Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f1777f31b | ||
|
|
05cf82dbbc | ||
|
|
8ae1e5d0d4 | ||
|
|
4bb21641d9 | ||
|
|
67d917010d | ||
|
|
a5a1976924 | ||
|
|
4806f88d7c |
@@ -31,8 +31,8 @@ describe('adminNavigation', () => {
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||
|
||||
@@ -135,7 +135,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '拼车接货', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
},
|
||||
@@ -147,6 +146,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
|
||||
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
|
||||
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,120 +1,69 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { scheduleTypes, type ScheduleEvent } from './schedule'
|
||||
|
||||
describe('appointment calendar interactions', () => {
|
||||
it('keeps creation beside the title and record actions beside data notices', () => {
|
||||
const wrapper = mount(AppointmentCalendarPanel, {
|
||||
props: { canCreate: true, events: [], notices: ['数据范围不完整'] },
|
||||
slots: { 'toolbar-actions': '<button>未定时安排</button><button>取消记录</button>' },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
expect(wrapper.get('.calendar-label').text()).toContain('新增预约')
|
||||
const actions = wrapper.get('[aria-label="预约记录与数据提示"]')
|
||||
expect(actions.text()).toContain('未定时安排')
|
||||
expect(actions.text()).toContain('取消记录')
|
||||
expect(actions.get('summary').text()).toContain('数据提示')
|
||||
wrapper.unmount()
|
||||
const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] })
|
||||
const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
describe('four-type appointment calendar', () => {
|
||||
it('shows all four types by default with a calendar and persistent daily detail', () => {
|
||||
const wrapper = render(scheduleTypes.map(type => event(type)))
|
||||
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
|
||||
expect(wrapper.findAll('.calendar-day')).toHaveLength(42)
|
||||
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
|
||||
})
|
||||
const render = () => mount(AppointmentCalendarPanel, {
|
||||
props: { initialDate: '2026-09-24', canCreate: true, events: [
|
||||
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
|
||||
] },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
|
||||
it('opens a readable appointment without exposing write actions', async () => {
|
||||
const wrapper = render()
|
||||
const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false}
|
||||
await wrapper.setProps({events:[event]})
|
||||
const actions=wrapper.get('.detail-event footer')
|
||||
expect(actions.text()).toBe('查看预约')
|
||||
await actions.get('button').trigger('click')
|
||||
expect(wrapper.emitted('view')?.[0]).toEqual([event])
|
||||
expect(wrapper.emitted('edit')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('hides optional references by default and counts only appointments after enabling them', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [
|
||||
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] },
|
||||
{ key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] },
|
||||
{ key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] },
|
||||
] })
|
||||
it('filters locally without requesting another range', async () => {
|
||||
const wrapper = render([event('arrival'), event('dispatch')])
|
||||
await wrapper.get('[aria-label="安排类型"] input').setValue(false)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
const switches = wrapper.get('[aria-label="参考图层"]').findAll('input')
|
||||
await switches[0]!.setValue(true); await switches[1]!.setValue(true)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(3)
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约')
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划')
|
||||
await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing')
|
||||
expect(wrapper.text()).toContain('当天暂无安排')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
it('selects an already loaded date without requesting data again', async () => {
|
||||
it('changes the selected date locally and requests one range per mode or period change', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
|
||||
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
|
||||
})
|
||||
|
||||
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [
|
||||
{ key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] },
|
||||
{ key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] },
|
||||
] })
|
||||
expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓')
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requests exactly one range for a view switch and navigates seven days', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20')
|
||||
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]])
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]])
|
||||
await wrapper.get('[aria-label="下一周"]').trigger('click')
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }])
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }])
|
||||
})
|
||||
|
||||
it('keeps busy days to three previews in both views and exposes the full count', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: Array.from({ length: 233 }, (_, index) => ({
|
||||
key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
|
||||
title: `船舶 ${index}`, fields: [],
|
||||
})) })
|
||||
await wrapper.get('[aria-label="参考图层"] input').setValue(true)
|
||||
for (const view of ['month', 'week']) {
|
||||
if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
|
||||
expect(day.findAll('.event-chip')).toHaveLength(3)
|
||||
expect(day.attributes('title')).toContain('共 233 条安排')
|
||||
expect(day.get('.event-more').text()).toBe('还有 230 条')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(233)
|
||||
}
|
||||
it('limits previews to three while retaining every event in the daily details', () => {
|
||||
const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index)))
|
||||
expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3)
|
||||
expect(wrapper.get('.event-more').text()).toBe('还有 20 条')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(23)
|
||||
})
|
||||
|
||||
it('uses the selected day for creation and emits edit/delete only from detail actions', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
|
||||
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click')
|
||||
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
it('keeps undated records outside date cells and exposes them explicitly', async () => {
|
||||
const wrapper = render([{ ...event('warehouse'), date: null }])
|
||||
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
|
||||
await wrapper.get('.undated-button').trigger('click')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定')
|
||||
})
|
||||
|
||||
it('renders many inbound codes outside the two-column fields with only three previews', async () => {
|
||||
it('retains business links and exposes editing only with authoritative eligibility', async () => {
|
||||
const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true }
|
||||
const wrapper = render([row])
|
||||
await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click')
|
||||
await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('open')).toEqual([[row]])
|
||||
expect(wrapper.emitted('edit')).toEqual([[row]])
|
||||
await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] })
|
||||
expect(wrapper.find('.detail-event footer').exists()).toBe(false)
|
||||
})
|
||||
it('distinguishes loading and partial failure from a complete empty day', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [{
|
||||
key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
|
||||
fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
|
||||
inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
|
||||
}] })
|
||||
const card = wrapper.get('.detail-event')
|
||||
expect(card.get('dl').text()).not.toContain('WT-INBOUND')
|
||||
expect(card.findAll('.inbound-codes li')).toHaveLength(3)
|
||||
expect(card.get('.inbound-codes').text()).toContain('20 个')
|
||||
expect(card.get('footer').text()).toContain('编辑')
|
||||
await wrapper.setProps({ loading: true })
|
||||
expect(wrapper.text()).toContain('正在读取安排')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
await wrapper.setProps({ loading: false, incomplete: true })
|
||||
expect(wrapper.text()).toContain('部分数据不可用')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,82 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, occursOnDate, type CalendarMode } from './calendar'
|
||||
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
|
||||
|
||||
export interface CalendarEvent {
|
||||
key: string
|
||||
date: string
|
||||
endAt?: string
|
||||
time: string
|
||||
type: 'arrival' | 'delivery' | 'pickup' | 'trip'
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
inboundCodes?: Array<string | null | undefined>
|
||||
canEdit?: boolean
|
||||
canView?: boolean
|
||||
canDelete?: boolean
|
||||
sourcePath?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
notices?: string[]
|
||||
events: CalendarEvent[]
|
||||
loading?: boolean
|
||||
incomplete?: boolean
|
||||
canCreate?: boolean
|
||||
initialDate?: string
|
||||
}>(), { notices: () => [], loading: false, incomplete: false, canCreate: false, initialDate: '' })
|
||||
const emit = defineEmits<{
|
||||
layers: [layers: { arrivals: boolean; trips: boolean }]
|
||||
range: [range: { from: string; to: string }]
|
||||
create: [date: string]
|
||||
edit: [event: CalendarEvent]
|
||||
view: [event: CalendarEvent]
|
||||
delete: [event: CalendarEvent]
|
||||
open: [event: CalendarEvent]
|
||||
}>()
|
||||
const showArrivals = ref(false), showTrips = ref(false)
|
||||
function updateLayers() { emit('layers', { arrivals: showArrivals.value, trips: showTrips.value }) }
|
||||
const visibleEvents = computed(() => props.events.filter(event => (event.type !== 'arrival' || showArrivals.value) && (event.type !== 'trip' || showTrips.value)))
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date()))
|
||||
const mode = ref<CalendarMode>('month')
|
||||
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode }>(),
|
||||
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month' })
|
||||
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent] }>()
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
|
||||
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
|
||||
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||
const visibleEvents = computed(() => props.events.filter(event => types.value.includes(event.type) &&
|
||||
`${event.title} ${event.fields.map(field => field.value || '').join(' ')}`.toLowerCase().includes(keyword.value.trim().toLowerCase())))
|
||||
const grouped = computed(() => {
|
||||
const result = new Map<string, CalendarEvent[]>()
|
||||
const result = new Map<string, ScheduleEvent[]>()
|
||||
for (const event of visibleEvents.value) {
|
||||
for (const date of dates.value) {
|
||||
if (!occursOnDate(event.date, event.endAt, date)) continue
|
||||
const group = result.get(date) || []
|
||||
group.push(event)
|
||||
result.set(date, group)
|
||||
}
|
||||
const date = event.date || 'undated'
|
||||
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
|
||||
}
|
||||
for (const group of result.values()) group.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
for (const rows of result.values()) rows.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
return result
|
||||
})
|
||||
const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || [])
|
||||
const appointmentCount = computed(() => selectedEvents.value.filter(event => event.type === 'delivery' || event.type === 'pickup').length)
|
||||
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' }
|
||||
const heading = computed(() => mode.value === 'month'
|
||||
? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月`
|
||||
: `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`)
|
||||
const selectedEvents = computed(() => grouped.value.get(undated.value ? 'undated' : selectedDate.value) || [])
|
||||
const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${dates.value.at(-1)}`)
|
||||
function select(date: string) {
|
||||
const before = calendarRange(selectedDate.value, mode.value)
|
||||
selectedDate.value = date
|
||||
selectedDate.value = date; undated.value = false
|
||||
const after = calendarRange(date, mode.value)
|
||||
emit('select', date, mode.value)
|
||||
if (before.from !== after.from || before.to !== after.to) emit('range', after)
|
||||
}
|
||||
function changeMode(next: CalendarMode) {
|
||||
if (mode.value === next) return
|
||||
mode.value = next
|
||||
emit('range', calendarRange(selectedDate.value, next))
|
||||
mode.value = next; emit('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
|
||||
}
|
||||
defineExpose({ selectedDate, mode })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="appointment-calendar admin-density" :aria-busy="loading">
|
||||
<div class="calendar-main">
|
||||
@@ -84,126 +42,75 @@ defineExpose({ selectedDate, mode })
|
||||
<div class="calendar-label">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
<strong>预约日历</strong>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<div class="view-toggle" role="group" aria-label="预约视图">
|
||||
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
|
||||
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
|
||||
</div>
|
||||
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
|
||||
<strong class="calendar-period">{{ heading }}</strong>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
|
||||
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||
</div>
|
||||
<div class="calendar-record-actions" role="group" aria-label="预约记录与数据提示">
|
||||
<slot name="toolbar-actions" />
|
||||
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-options">
|
||||
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
|
||||
<div class="calendar-layers" role="group" aria-label="参考图层"><label><input v-model="showArrivals" type="checkbox" @change="updateLayers">显示到港参考</label><label><input v-model="showTrips" type="checkbox" @change="updateLayers">显示用车计划</label></div>
|
||||
<div class="calendar-layers" role="group" aria-label="安排类型"><label v-for="type in scheduleTypes" :key="type" class="legend" :class="type"><input v-model="types" type="checkbox" :value="type">{{ scheduleLabels[type] }}</label></div>
|
||||
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input v-model="keyword" type="search" maxlength="128" placeholder="柜号、车次号、仓库" aria-label="搜索当前日历"></label>
|
||||
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
|
||||
<div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<button v-for="date in dates" :key="date" type="button" class="calendar-day"
|
||||
:class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||
:title="`${date},${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-pressed="selectedDate === date" @click="select(date)">
|
||||
<span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span>
|
||||
<span class="day-events">
|
||||
<span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title} · 当天${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`">
|
||||
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
|
||||
</span>
|
||||
<span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span>
|
||||
</span>
|
||||
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
||||
<div class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<button v-for="date in dates" :key="date" type="button" class="calendar-day" :data-date="date"
|
||||
:class="{ selected: !undated && selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||
:aria-label="`${date},${loading ? '加载中' : `${(grouped.get(date) || []).length} 项安排`}`" :aria-pressed="!undated && selectedDate === date" @click="select(date)">
|
||||
<span class="day-number">{{ Number(date.slice(8)) }}</span>
|
||||
<span v-if="!loading" class="day-events"><span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="event.title">{{ event.title }}</span><span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail" aria-label="当天安排">
|
||||
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ appointmentCount }}</b> 项预约<span v-if="selectedEvents.length > appointmentCount">,{{ selectedEvents.length - appointmentCount }} 条参考计划</span><span v-if="incomplete">(当前范围不完整)</span></p></header>
|
||||
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
|
||||
<div class="detail-events" aria-live="polite">
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</p>
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ eventTypeLabels[event.type] }}</span><span>{{ event.time || '—' }}</span></div>
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
|
||||
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||
<dl><div v-for="field in event.fields" :key="field.label" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
|
||||
<footer v-if="event.canView || event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
|
||||
<el-button v-if="event.canView" class="density-action" :disabled="loading" @click="emit('view', event)">查看预约</el-button>
|
||||
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
|
||||
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
|
||||
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
|
||||
</footer>
|
||||
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<footer v-if="event.sourcePath || event.canEdit"><el-button v-if="event.sourcePath" link type="primary" :disabled="loading" @click="emit('open', event)">{{ event.type === 'dispatch' ? '查看排车' : '查看货柜' }}</el-button><el-button v-if="event.canEdit" link type="primary" :disabled="loading" @click="emit('edit', event)">调整计划到仓</el-button></footer>
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-data-notices { position: relative; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.calendar-data-notices summary { cursor: pointer; padding: var(--admin-space-2) var(--admin-space-3); }
|
||||
.calendar-notice-content { position: absolute; top: 100%; right: 0; z-index: 5; width: 320px; padding: var(--admin-space-5); border: 1px solid var(--admin-table-border); border-radius: var(--admin-radius); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); }
|
||||
.calendar-notice-content p { margin: 0; line-height: 1.6; }
|
||||
.calendar-notice-content p + p { margin-top: var(--admin-space-3); }
|
||||
|
||||
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: #344054; }
|
||||
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
|
||||
.calendar-main, .calendar-detail { background: var(--admin-surface); border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
|
||||
.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.calendar-label > svg { color: #617ce9; }
|
||||
.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
|
||||
.calendar-legend, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||||
.calendar-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-1); cursor: pointer; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.calendar-layers input { margin: 0; accent-color: var(--el-color-primary); }
|
||||
.legend { font-size: 12px; white-space: nowrap; }
|
||||
.legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
|
||||
.legend.delivery::before { background: #27a87a; }
|
||||
.legend.pickup::before { background: #c17a20; }
|
||||
.legend.trip::before { background: #8562b0; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.calendar-label > svg { color: #617ce9; }.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
|
||||
.calendar-search { margin-left: auto; }.calendar-search input { box-sizing: border-box; width: 190px; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); font: inherit; }
|
||||
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
|
||||
.view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; }
|
||||
.view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; }
|
||||
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: 14px; white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); font-size: var(--admin-font-caption); }
|
||||
.view-toggle button, .nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
|
||||
.view-toggle button[aria-pressed='true'], .undated-button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 600; }.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
|
||||
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 112px; min-width: 0; padding: var(--admin-space-2); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
|
||||
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }
|
||||
.calendar-day:hover { border-color: #bac8ed; background: #f8faff; }
|
||||
.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
|
||||
.calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
|
||||
.day-number { display: block; font-size: 12px; font-weight: 600; }
|
||||
.day-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--admin-space-1); }
|
||||
.event-chip { display: block; padding: 1px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }
|
||||
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
|
||||
.pickup { --event-bg: #fff4e3; --event-fg: #995a10; }
|
||||
.trip { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }
|
||||
.event-chip small { display: block; margin-top: 2px; font-size: 11px; opacity: .8; }
|
||||
.event-more { padding: 1px 6px; line-height: 16px; }
|
||||
.event-more, .empty-day { color: #667085; font-size: 12px; }
|
||||
.week-grid .calendar-day { height: 220px; }
|
||||
.calendar-detail { align-self: start; }
|
||||
.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||
.calendar-detail header p { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-caption); color: #8491a5; }
|
||||
.calendar-detail header b { color: #617ce9; font-size: var(--admin-font-title); }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: 660px; overflow-y: auto; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
||||
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }.calendar-day:hover { border-color: #bac8ed; }.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
|
||||
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
|
||||
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
|
||||
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: #617ce9; }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
|
||||
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
|
||||
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
|
||||
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
|
||||
.event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }
|
||||
.detail-event .wide-field { grid-column: 1 / -1; }
|
||||
.detail-event dt { font-size: 12px; color: #8491a5; }
|
||||
.detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
|
||||
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
|
||||
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid #edf0f6; padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -1,235 +1,118 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { inject, onBeforeUnmount } from 'vue'
|
||||
import { ElDialog } from 'element-plus'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
|
||||
import { listArrivalCalendar } from './api'
|
||||
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
|
||||
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
|
||||
function render(realDialog = false) {
|
||||
return mount(AppointmentCalendarView, { attachTo: document.body, global: { components: { ElDialog }, directives: { loading: {} }, stubs: {
|
||||
teleport: true,
|
||||
ElTooltip: { template: '<slot />' },
|
||||
ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '<div v-if="modelValue" class="el-dialog" :class="{ \'is-fullscreen\': fullscreen }"><button aria-label="关闭工作面板" @click="beforeClose(() => $emit(\'update:modelValue\', false))">关闭</button><slot name="header" /><slot /><slot name="footer" /></div>' },
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
ElButton: { props: ['type'], template: '<button type="button" :data-variant="type"><slot /></button>' },
|
||||
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '<div data-calendar><slot name="inbox" /></div>' },
|
||||
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
|
||||
const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
|
||||
const unregister = register(async () => routing.allowLeave)
|
||||
onBeforeUnmount(unregister)
|
||||
}, template: '<div data-detail>{{ itemKey }}</div>' },
|
||||
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
|
||||
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
|
||||
ApiErrorMeta: true,
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode'], template: '<section data-calendar />' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
|
||||
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
|
||||
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
|
||||
} } })
|
||||
}
|
||||
describe('appointment work calendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
|
||||
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
|
||||
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
|
||||
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||||
})
|
||||
it('retains the old batch and page when a resolved later batch continues into its vehicle work', async () => {
|
||||
routing.query = { workAnchor: '2026-10-15', workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'old-batch-work' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-batch-work'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-vehicle-work', followAction); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-vehicle-work')
|
||||
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
|
||||
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('old-batch-work')
|
||||
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ view: 'UNDATED', page: 3 }), expect.any(AbortSignal))
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
})
|
||||
describe('simplified appointment entry', () => {
|
||||
it('keeps unsaved input when the operator declines to leave', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(true)
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('预约工作日历')
|
||||
expect(wrapper.get('[data-calendar] .work-inbox').attributes('aria-label')).toBe('全局待办')
|
||||
expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
|
||||
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-09' }, ['1'], expect.any(AbortSignal))
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
|
||||
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps day/type selection and pagination context in the overlay and clears selection on close', async () => {
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 30, pages: 4 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
|
||||
calendar.vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
|
||||
expect(calendar.props()).toMatchObject({ selectedDate: '2026-10-15', selectedType: 'PICKUP' })
|
||||
expect(wrapper.get('.panel-header').text()).toContain('2026-10-15 · 提柜')
|
||||
expect(wrapper.find('.panel-body .work-pagination').exists()).toBe(false)
|
||||
expect(wrapper.get('.panel-footer').text()).toContain('共 30 项')
|
||||
await wrapper.get('.panel-footer .work-pagination button:last-child').trigger('click'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-10-15', types: ['PICKUP'], page: 2 }), expect.any(AbortSignal))
|
||||
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('.work-panel').exists()).toBe(false)
|
||||
expect(calendar.props('selectedDate')).toBeUndefined()
|
||||
it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
|
||||
vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
|
||||
expect(wrapper.text()).toContain('计划到仓读取失败')
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('expands the existing detail without remounting or bypassing the unsaved close guard', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
|
||||
routing.allowLeave = false
|
||||
await wrapper.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(wrapper.get('.el-dialog').classes()).toContain('is-fullscreen')
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
|
||||
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('.work-panel').exists()).toBe(true)
|
||||
routing.allowLeave = true
|
||||
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('.work-panel').exists()).toBe(false)
|
||||
it('does not request unreadable domains and describes the incomplete scope', async () => {
|
||||
routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少车次读取权限')
|
||||
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['mask', 'escape'] as const)('closes from %s through the real dialog without bypassing draft protection', async method => {
|
||||
const wrapper = render(true); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.panel-body').trigger('click')
|
||||
expect(wrapper.find('[data-detail]').exists()).toBe(true)
|
||||
const dismiss = async () => {
|
||||
if (method === 'mask') {
|
||||
const mask = wrapper.get('.el-overlay-dialog')
|
||||
await mask.trigger('mousedown'); await mask.trigger('mouseup'); await mask.trigger('click')
|
||||
} else {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }))
|
||||
}
|
||||
await flushPromises()
|
||||
}
|
||||
routing.allowLeave = false
|
||||
await dismiss()
|
||||
expect(wrapper.find('[data-detail]').exists()).toBe(true)
|
||||
routing.allowLeave = true
|
||||
await dismiss()
|
||||
expect(wrapper.find('.work-panel').exists()).toBe(false)
|
||||
it('ignores stale calendar responses when the date range changes', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof getCalendarSources>>) => void
|
||||
vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
|
||||
resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('缺少还柜读取权限')
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['VIEW_DETAIL', 'VIEW_HISTORY', 'SAVE_FOLLOWUP'] as const)('keeps %s navigation intact while distinguishing read-only actions', async code => {
|
||||
const nextAction = { ...followAction, code, label: '服务端动作文案' }
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ nextAction })], page: 1, size: 8, total: 1, pages: 1 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
const button = wrapper.get('.work-row-action button')
|
||||
expect(button.attributes('data-variant')).toBe(code === 'SAVE_FOLLOWUP' ? 'primary' : 'default')
|
||||
expect(wrapper.get('.item-schedule').text()).toContain('作业日期未定')
|
||||
expect(wrapper.get('.item-schedule').text()).toContain('America/Los_Angeles')
|
||||
expect(wrapper.get('.item-context').text()).toContain('地点:ONT8')
|
||||
expect(wrapper.get('.item-context').text()).toContain('负责人:测试调度')
|
||||
expect(wrapper.get('.item-context').text()).toContain('跟进:2026-10-15')
|
||||
await button.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(nextAction)
|
||||
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('提交结果尚未确认')
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('sends an explicit safe return context with external business navigation', async () => {
|
||||
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
|
||||
expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
|
||||
it('requires an explicit reread after a conflict and retains the entered date', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps a successful save in the current detail and retains the original global page on return', async () => {
|
||||
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
|
||||
detail.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
|
||||
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-detail]').exists()).toBe(false)
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
|
||||
it('keeps the selected date and view in the source return link', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||
panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
|
||||
calendar.vm.$emit('anchor', 1); await flushPromises()
|
||||
calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
|
||||
const calls = vi.mocked(getWorkCalendar).mock.calls.length
|
||||
calendar.vm.$emit('types', []); await flushPromises()
|
||||
expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
|
||||
await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows a failed source as an error, not zero work or all complete', async () => {
|
||||
vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
|
||||
vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
|
||||
vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
|
||||
expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
|
||||
expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a late response after the global scope changes', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof getWorkItems>>) => void
|
||||
vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="OVERDUE"]').trigger('click')
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
|
||||
expect(wrapper.text()).toContain('返回原事项')
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('goes directly to the current container unfinished list after route confirmation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
|
||||
routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
|
||||
wrapper.unmount()
|
||||
routing.canReadSource = false
|
||||
const denied = render(); await flushPromises()
|
||||
expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
|
||||
expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,287 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { isAxiosError } from 'axios'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { addDays, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { listArrivalCalendar, type ArrivalCalendarEntry } from './api'
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary, workTypes, type WorkAction, type WorkCalendar, type WorkCommand, type WorkCommandResult, type WorkBucket, type WorkFlow, type WorkItemsQuery, type WorkPage, type WorkSummary, type WorkType, type WorkView } from './workApi'
|
||||
import { flowLabels, typeLabels, viewLabels, workDate, workStatus } from './workPresentation'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import WorkCalendarPanel from './WorkCalendarPanel.vue'
|
||||
import WorkDetailPanel from './WorkDetailPanel.vue'
|
||||
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||||
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||||
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||||
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import type { WorkAction } from './workApi'
|
||||
import { appointmentExternalTarget } from './workNavigation'
|
||||
|
||||
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
|
||||
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
|
||||
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
|
||||
const savedAnchor = stringQuery('workAnchor')
|
||||
function initialAnchor() { try { calendarRange(savedAnchor, 'month'); return savedAnchor } catch { return dateKey(new Date()) } }
|
||||
const anchor = ref(initialAnchor())
|
||||
const mode = ref<CalendarMode>(stringQuery('workMode') === 'week' ? 'week' : 'month')
|
||||
const flow = ref<WorkFlow | ''>(['TRANSFER', 'DIRECT', 'UNKNOWN'].includes(stringQuery('workFlow')) ? stringQuery('workFlow') as WorkFlow : '')
|
||||
const types = ref<WorkType[]>(route.query.workTypes !== undefined ? workTypes.filter(type => stringQuery('workTypes').split(',').includes(type)) : [...workTypes])
|
||||
const reference = ref(false), arrivals = ref<ArrivalCalendarEntry[]>([]), referenceReady = ref(false)
|
||||
const calendar = ref<WorkCalendar | null>(null), summary = ref<WorkSummary | null>(null), items = ref<WorkPage | null>(null)
|
||||
const calendarLoading = ref(false), summaryLoading = ref(false), itemsLoading = ref(false), referenceLoading = ref(false)
|
||||
const calendarError = ref<ApiErrorDetails | null>(null), summaryError = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), referenceError = ref<ApiErrorDetails | null>(null)
|
||||
type Scope = { kind: 'global'; view: WorkView } | { kind: 'day'; date: string; types: WorkType[]; flow: WorkFlow | ''; bucket?: WorkBucket } | { kind: 'container'; containerBookingId: string; label: string } | { kind: 'search'; keyword: string } | { kind: 'arrivals'; date: string }
|
||||
const scope = ref<Scope | null>(null), page = ref(1), detailKey = ref<string | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||
const origins = ref<string[]>([]), search = ref(''), sourceLink = ref<PlanningSourceLookup | null>(null), linkError = ref('')
|
||||
const sourceLinkKey = computed(() => sourceLink.value ? `${sourceLink.value.customerId}:${'sourceKey' in sourceLink.value ? sourceLink.value.sourceKey : sourceLink.value.orderId}:${sourceLink.value.destinationKey || ''}` : '')
|
||||
const navigationError = ref('')
|
||||
const summaryButtons: Array<{ view: WorkView; field: keyof WorkSummary }> = [{ view: 'ALL', field: 'all' }, { view: 'FOLLOW_TODAY', field: 'followToday' }, { view: 'OVERDUE', field: 'overdue' }, { view: 'UNDATED', field: 'undated' }, { view: 'REVIEW', field: 'review' }]
|
||||
const buckets: Array<{ key?: WorkBucket; label: string }> = [{ label: '全部' }, { key: 'PENDING', label: '待安排' }, { key: 'CONFIRMED', label: '已落实' }, { key: 'NO_BOOKING_REQUIRED', label: '无需预约' }, { key: 'REVIEW', label: '需复核' }, { key: 'COMPLETED', label: '已完成' }]
|
||||
const range = computed(() => calendarRange(anchor.value, mode.value))
|
||||
const heading = computed(() => {
|
||||
const value = scope.value
|
||||
if (!value) return ''
|
||||
if (value.kind === 'global') return viewLabels[value.view]
|
||||
if (value.kind === 'day') return `${value.date} · ${value.types.length === 1 ? typeLabels[value.types[0]!] : '当天工作'}`
|
||||
if (value.kind === 'container') return `${value.label} · 本柜待办`
|
||||
if (value.kind === 'arrivals') return `${value.date} · 到港参考`
|
||||
return `查找:${value.keyword}`
|
||||
})
|
||||
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
|
||||
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
|
||||
const panelBody = ref<HTMLElement | null>(null)
|
||||
const selectedDate = computed(() => scope.value?.kind === 'day' || scope.value?.kind === 'arrivals' ? scope.value.date : undefined)
|
||||
const selectedType = computed(() => scope.value?.kind === 'day' && scope.value.types.length === 1 ? scope.value.types[0] : undefined)
|
||||
const panelTitle = computed(() => sourceLink.value ? '货源与预约' : detailKey.value ? '事项详情' : heading.value)
|
||||
const listVisible = computed(() => !sourceLink.value && !detailKey.value && scope.value?.kind !== 'arrivals')
|
||||
watch([detailKey, sourceLinkKey, scope, page], () => { if (panelBody.value) panelBody.value.scrollTop = 0 }, { flush: 'post' })
|
||||
function initialDate() {
|
||||
const value = typeof route.query.date === 'string' ? route.query.date : ''
|
||||
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
|
||||
}
|
||||
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
|
||||
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||||
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
||||
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
|
||||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
|
||||
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||||
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||||
let generation = 0, controller: AbortController | null = null
|
||||
async function load(next = range.value) {
|
||||
range.value = next
|
||||
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
||||
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
let result: CalendarSources | null = null
|
||||
try {
|
||||
result = await getCalendarSources(next, signal)
|
||||
if (request !== generation) return
|
||||
sources.value = result
|
||||
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
|
||||
} catch (cause) { fail('到港与还柜读取失败', cause) }
|
||||
if (request !== generation) return
|
||||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||||
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
|
||||
}
|
||||
try {
|
||||
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
|
||||
if (request === generation) warehouses.value = rows
|
||||
} catch (cause) { fail('计划到仓读取失败', cause) }
|
||||
})(),
|
||||
(async () => {
|
||||
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
|
||||
try {
|
||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||
if (request !== generation) return
|
||||
trips.value = result.events
|
||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
if (request === generation) loading.value = false
|
||||
}
|
||||
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
|
||||
async function openSource(event: ScheduleEvent) {
|
||||
if (!event.sourcePath || !await canLeave()) return
|
||||
const context = { date: selectedDate.value, mode: mode.value }
|
||||
await router.replace({ query: context })
|
||||
await router.push(appointmentExternalTarget(event.sourcePath, context))
|
||||
}
|
||||
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
|
||||
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
|
||||
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
|
||||
function edit(event: ScheduleEvent) {
|
||||
if (!event.canEdit || !event.warehouse) return
|
||||
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
|
||||
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
|
||||
}
|
||||
async function save() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row || saving.value || conflict.value || !plannedAt.value) return
|
||||
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||
key: `calendar-arrival-${crypto.randomUUID()}` }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await saveWarehouseArrival(pending.id, pending.body, pending.key)
|
||||
pending = null; unknownResult.value = false; editor.value = null
|
||||
ElMessage.success('计划到仓已保存')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
|
||||
unknownResult.value = !rejectedCommand(cause)
|
||||
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
|
||||
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
|
||||
if (!unknownResult.value) pending = null
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function rereadEditor() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row) return
|
||||
saving.value = true
|
||||
try {
|
||||
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
|
||||
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
|
||||
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
|
||||
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => { guards.delete(guard) } })
|
||||
const canLeave: LeaveGuard = async () => { for (const guard of guards) if (!await guard()) return false; return true }
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||
async function canLeave() {
|
||||
if (saving.value || unknownResult.value) return false
|
||||
for (const guard of guards) if (!await guard()) return false
|
||||
if (editor.value && plannedAt.value !== originalAt.value) {
|
||||
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
|
||||
catch { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
|
||||
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
|
||||
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
|
||||
async function loadCalendar() {
|
||||
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
|
||||
if (!types.value.length) { calendarLoading.value = false; return }
|
||||
calendarLoading.value = true
|
||||
try {
|
||||
const result = await getWorkCalendar({ from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }, calendarController.signal)
|
||||
if (request === calendarRequest) calendar.value = result
|
||||
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
|
||||
finally { if (request === calendarRequest) calendarLoading.value = false }
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
|
||||
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
|
||||
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
|
||||
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
|
||||
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
|
||||
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
|
||||
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
|
||||
origins.value = []
|
||||
initialAction.value = null
|
||||
}, { immediate: true })
|
||||
async function openWork(key: string, action?: WorkAction) {
|
||||
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
|
||||
}
|
||||
async function loadSummary() {
|
||||
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
|
||||
try { const result = await getWorkSummary(summaryController.signal); if (request === summaryRequest) summary.value = result }
|
||||
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
|
||||
finally { if (request === summaryRequest) summaryLoading.value = false }
|
||||
}
|
||||
function itemQuery(): WorkItemsQuery | null {
|
||||
const value = scope.value
|
||||
if (!value || value.kind === 'arrivals') return null
|
||||
const query: WorkItemsQuery = { view: value.kind === 'global' ? value.view : 'ALL', page: page.value, size: 8 }
|
||||
if (value.kind === 'day') Object.assign(query, { date: value.date, types: value.types, flow: value.flow || undefined, bucket: value.bucket })
|
||||
if (value.kind === 'container') query.containerBookingId = value.containerBookingId
|
||||
if (value.kind === 'search') query.keyword = value.keyword
|
||||
return query
|
||||
}
|
||||
async function loadItems() {
|
||||
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
|
||||
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
|
||||
itemsLoading.value = true
|
||||
try { const result = await getWorkItems(query, itemsController.signal); if (request === itemsRequest) items.value = result }
|
||||
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
|
||||
finally { if (request === itemsRequest) itemsLoading.value = false }
|
||||
}
|
||||
async function loadArrivals() {
|
||||
const request = ++referenceRequest; referenceError.value = null; referenceReady.value = false; arrivals.value = []
|
||||
if (!reference.value) { referenceLoading.value = false; return }
|
||||
if (!access.can('platform:firstleg:port-schedule:list')) { referenceError.value = { message: '当前账号没有到港参考读取权限,工作事项仍可正常查看。' }; return }
|
||||
referenceLoading.value = true
|
||||
try { const result = await listArrivalCalendar({ ...range.value }); if (request === referenceRequest) { arrivals.value = result; referenceReady.value = true } }
|
||||
catch (cause) { if (request === referenceRequest) referenceError.value = getApiErrorDetails(cause, '到港参考读取失败') }
|
||||
finally { if (request === referenceRequest) referenceLoading.value = false }
|
||||
}
|
||||
async function setScope(value: Scope) {
|
||||
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
|
||||
async function closeBusiness(done: () => void) {
|
||||
if (!await canLeave()) return
|
||||
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
|
||||
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
|
||||
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
|
||||
}
|
||||
async function openItem(key: string, action?: WorkAction) {
|
||||
if (!await canLeave()) return
|
||||
navigationError.value = ''
|
||||
if (action && origins.value.includes(key)) { navigationError.value = '前置事项关系需要核对,请返回原事项并刷新后重试。'; return }
|
||||
if (detailKey.value && detailKey.value !== key) origins.value.push(detailKey.value)
|
||||
detailKey.value = key; initialAction.value = action || null; sourceLink.value = null
|
||||
}
|
||||
async function back() {
|
||||
if (!await canLeave()) return
|
||||
const origin = origins.value.pop()
|
||||
detailKey.value = origin || null; initialAction.value = null; sourceLink.value = null
|
||||
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
|
||||
if (!origin) await loadItems()
|
||||
}
|
||||
let closingPanel = false
|
||||
async function closePanel(done: () => void) {
|
||||
if (closingPanel) return
|
||||
closingPanel = true
|
||||
try {
|
||||
if (!await canLeave()) return
|
||||
done()
|
||||
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
|
||||
} finally { closingPanel = false }
|
||||
}
|
||||
async function filterCalendar(change: () => void) {
|
||||
if (!await canLeave()) return
|
||||
change()
|
||||
if (scope.value?.kind === 'day' || scope.value?.kind === 'arrivals') { scope.value = null; detailKey.value = null; initialAction.value = null; origins.value = [] }
|
||||
await Promise.all([loadCalendar(), loadArrivals()])
|
||||
}
|
||||
async function toggleReference(value: boolean) { reference.value = value; await loadArrivals() }
|
||||
async function searchItems() { if (search.value.trim()) await setScope({ kind: 'search', keyword: search.value.trim() }) }
|
||||
async function changePage(next: number) { page.value = next; await loadItems() }
|
||||
async function setBucket(bucket?: WorkBucket) { if (scope.value?.kind === 'day') { scope.value = { ...scope.value, bucket }; page.value = 1; await loadItems() } }
|
||||
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
|
||||
if (command === 'CONFIRM_ROUTE' && result?.item.containers[0]) {
|
||||
const container = result.item.containers[0]
|
||||
scope.value = { kind: 'container', containerBookingId: container.containerBookingId, label: container.containerNo || container.bookingNo || '本柜' }; page.value = 1; detailKey.value = null; origins.value = []; initialAction.value = null
|
||||
} else { if (origins.value.length) detailKey.value = origins.value.pop()!; initialAction.value = null; sourceLink.value = null }
|
||||
await Promise.all([loadCalendar(), loadSummary(), loadItems()])
|
||||
}
|
||||
async function refreshWork() { await Promise.all([loadCalendar(), loadSummary(), loadItems()]) }
|
||||
async function external(path: string) {
|
||||
if (!await canLeave()) return
|
||||
const value = scope.value
|
||||
const context = { workAnchor: anchor.value, workMode: mode.value, workFlow: flow.value || undefined, workTypes: types.value.join(','), workItem: detailKey.value || undefined, workOrigins: origins.value.length ? JSON.stringify(origins.value) : undefined, workScope: value?.kind, workView: value?.kind === 'global' ? value.view : undefined, workDate: value?.kind === 'day' ? value.date : undefined, workDayTypes: value?.kind === 'day' ? value.types.join(',') : undefined, workBucket: value?.kind === 'day' ? value.bucket : undefined, workContainer: value?.kind === 'container' ? value.containerBookingId : undefined, workContainerLabel: value?.kind === 'container' ? value.label : undefined, workKeyword: value?.kind === 'search' ? value.keyword : undefined, workPage: String(page.value) }
|
||||
await router.replace({ query: context })
|
||||
await router.push(appointmentExternalTarget(path, context))
|
||||
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
|
||||
}
|
||||
onMounted(() => {
|
||||
const scopeKind = stringQuery('workScope'), restoredView = stringQuery('workView')
|
||||
if (scopeKind === 'container' && stringQuery('workContainer')) scope.value = { kind: 'container', containerBookingId: stringQuery('workContainer'), label: stringQuery('workContainerLabel') || '本柜' }
|
||||
else if (scopeKind === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(stringQuery('workDate'))) scope.value = { kind: 'day', date: stringQuery('workDate'), flow: flow.value, types: workTypes.filter(type => stringQuery('workDayTypes').split(',').includes(type)), bucket: buckets.find(bucket => bucket.key === stringQuery('workBucket'))?.key }
|
||||
else if (scopeKind === 'search') scope.value = { kind: 'search', keyword: stringQuery('workKeyword') }
|
||||
else if (scopeKind === 'global') scope.value = { kind: 'global', view: summaryButtons.some(button => button.view === restoredView) ? restoredView as WorkView : 'ALL' }
|
||||
const restoredPage = Number(stringQuery('workPage')); page.value = Number.isInteger(restoredPage) && restoredPage > 0 ? restoredPage : 1
|
||||
detailKey.value = stringQuery('workItem') || null
|
||||
try { const savedOrigins: unknown = JSON.parse(stringQuery('workOrigins') || '[]'); if (Array.isArray(savedOrigins) && savedOrigins.every(value => typeof value === 'string' && value.length < 256)) origins.value = savedOrigins.slice(-10) } catch { origins.value = [] }
|
||||
void loadCalendar(); void loadSummary(); if (scope.value) void loadItems()
|
||||
})
|
||||
watch(() => [route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||
sourceLink.value = null; linkError.value = ''
|
||||
const sourceKey = stringQuery('sourceKey'), orderId = stringQuery('orderId'), customerId = stringQuery('customerId'), destinationKey = stringQuery('destinationKey')
|
||||
if (sourceKey || orderId || customerId || destinationKey) {
|
||||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) linkError.value = '当前账号没有货源读取权限,请联系预约负责人处理。'
|
||||
else if (sourceKey && orderId) linkError.value = '货物链接同时包含两种来源,请从原业务重新打开。'
|
||||
else if (sourceKey && customerId) sourceLink.value = { sourceKey, customerId, destinationKey: destinationKey || undefined }
|
||||
else if (orderId && customerId) sourceLink.value = { orderId, customerId, destinationKey: destinationKey || undefined }
|
||||
else linkError.value = '货物链接缺少客户或原来源,请从原业务重新打开。'
|
||||
}
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
|
||||
onMounted(() => { void load() })
|
||||
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
|
||||
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
|
||||
onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density appointment-work" spacing="compact">
|
||||
<div class="work-toolbar">
|
||||
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
|
||||
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
|
||||
</div>
|
||||
<p v-if="linkError" role="alert">{{ linkError }}</p>
|
||||
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
|
||||
<div class="work-layout">
|
||||
<WorkCalendarPanel :selected-date="selectedDate" :selected-type="selectedType" :anchor="anchor" :mode="mode" :flow="flow" :types="types" :calendar="calendar" :loading="calendarLoading" :failed="!!calendarError" :reference="reference" :arrivals="arrivals" :reference-ready="referenceReady" @anchor="offset => filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })">
|
||||
<template #inbox>
|
||||
<nav class="work-inbox" aria-label="全局待办">
|
||||
<button v-for="button in summaryButtons" :key="button.view" type="button" :data-view="button.view" :aria-pressed="scope?.kind === 'global' && scope.view === button.view" @click="setScope({ kind: 'global', view: button.view })">{{ viewLabels[button.view] }} <strong :class="{ 'is-zero': !summaryLoading && !summaryError && summary?.[button.field] === 0, 'is-overdue': !summaryLoading && !summaryError && button.view === 'OVERDUE' && (summary?.overdue ?? 0) > 0 }">{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>全局待办 · 不受日历筛选影响 · 数量可重叠</span>
|
||||
</nav>
|
||||
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
|
||||
</template>
|
||||
</WorkCalendarPanel>
|
||||
<AppDialog :model-value="panelOpen" :title="panelTitle" class="admin-density-dialog admin-density appointment-work work-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :close-on-click-modal="true" :close-on-press-escape="true" :before-close="closePanel" :show-footer="listVisible && !!items && !itemsError && !itemsLoading" destroy-on-close>
|
||||
<template #header>
|
||||
<header class="panel-header">
|
||||
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><h3>{{ panelTitle }}</h3></div>
|
||||
<template v-if="!detailKey && !sourceLink">
|
||||
<p class="scope-description">{{ scopeDescription }}</p>
|
||||
<div v-if="scope?.kind === 'day'" class="bucket-buttons"><button v-for="bucket in buckets" :key="bucket.label" type="button" :aria-pressed="scope.bucket === bucket.key" @click="setBucket(bucket.key)">{{ bucket.label }}</button></div>
|
||||
</template>
|
||||
</header>
|
||||
</template>
|
||||
<section v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
|
||||
<div ref="panelBody" class="panel-body">
|
||||
<p v-if="origins.length" class="origin-context">正在处理关联前置事项,完成后可继续原事项。</p>
|
||||
<p v-if="navigationError" role="alert">{{ navigationError }}</p>
|
||||
<SourceAppointmentPanel v-if="sourceLink" :key="sourceLinkKey" :source="sourceLink" :can-leave="canLeave" @updated="refreshWork" />
|
||||
<WorkDetailPanel v-else-if="detailKey" :key="detailKey" :item-key="detailKey" :initial-action="initialAction" :can-leave="canLeave" @open="openItem" @external="external" @saved="saved" @updated="refreshWork" @container="(id, label) => setScope({ kind: 'container', containerBookingId: id, label })" />
|
||||
<template v-else-if="scope?.kind === 'arrivals'"><p v-for="arrival in arrivals.filter(row => row.arrivalAt.slice(0, 10) === (scope?.kind === 'arrivals' ? scope.date : ''))" :key="arrival.containerBookingId">{{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}</p><p>到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。</p></template>
|
||||
<template v-else>
|
||||
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
|
||||
<p v-if="itemsLoading" role="status">正在读取明细…</p>
|
||||
<template v-else-if="items && !itemsError">
|
||||
<article v-for="item in items.records" :key="item.workItemKey" class="work-row">
|
||||
<div class="work-row-action"><el-button v-if="item.nextAction" :type="['VIEW_DETAIL', 'VIEW_HISTORY'].includes(item.nextAction.code) ? 'default' : 'primary'" @click="openItem(item.workItemKey, item.nextAction)">{{ item.nextAction.label }}</el-button><el-button v-else @click="openItem(item.workItemKey)">查看详情</el-button></div>
|
||||
<div class="work-row-content">
|
||||
<div class="item-heading"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><span class="item-status">{{ workStatus(item) }}</span></div>
|
||||
<p class="item-schedule"><span class="item-date">{{ workDate(item) }}</span><span>{{ item.timezone || '地点时区待核实' }}</span></p>
|
||||
<p class="item-context"><span v-if="!item.title.endsWith(typeLabels[item.type])">{{ typeLabels[item.type] }}</span><span>{{ flowLabels[item.flow] }}</span><span>地点:{{ item.destinationName || item.originName || '地点待核实' }}</span><span>负责人:{{ item.owner?.name || '待分派' }}</span><span v-if="item.followupDate">跟进:{{ item.followupDate }}</span></p>
|
||||
<p v-for="reason in [...item.overdueReasons, ...item.blockReasons]" :key="reason" class="work-warning">{{ reason }}</p>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!items.records.length">当前范围没有工作事项。</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
<template #footer><footer v-if="items" class="panel-footer"><span>共 {{ items.total }} 项</span><div v-if="items.total > 8" class="work-pagination"><button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button><span>{{ page }} / {{ Math.max(1, Math.ceil(items.total / 8)) }}</span><button type="button" :disabled="page * 8 >= items.total" @click="changePage(page + 1)">下一页</button></div></footer></template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
<AdminPage class="admin-density appointment-schedule" spacing="compact">
|
||||
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" @range="load" @select="select" @open="openSource" @edit="edit" />
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<template v-if="editor">
|
||||
<p>{{ editor.title }}</p>
|
||||
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
|
||||
<label class="arrival-field">计划到仓时间<input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required></label>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
|
||||
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
|
||||
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
|
||||
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.work-toolbar, .work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-3); flex-wrap: wrap; }
|
||||
.work-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); }
|
||||
.work-heading h2 { margin: 0; }
|
||||
.work-heading span, .work-inbox > span, .work-search > span, .scope-description { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-inbox { padding: var(--admin-space-2) var(--admin-space-5); }
|
||||
.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }
|
||||
.work-inbox strong { font-variant-numeric: tabular-nums; }
|
||||
.work-inbox strong.is-zero { color: var(--el-text-color-secondary); font-weight: 400; }
|
||||
.work-inbox strong.is-overdue { color: var(--el-color-danger-dark-2); }
|
||||
.appointment-work .work-inbox button[aria-pressed=true] { box-shadow: inset 0 -2px 0 var(--el-color-primary); }
|
||||
.work-search input { width: 280px; }
|
||||
.appointment-work button:not(.el-button), .appointment-work input { font: inherit; }
|
||||
.appointment-work button:not(.el-button) { cursor: pointer; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
|
||||
.appointment-work button:not(.el-button)[aria-pressed=true] { color: var(--el-color-primary); border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.appointment-work input { min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); }
|
||||
.appointment-work button:not(.el-button):focus-visible, .appointment-work input:focus-visible, .panel-header h3:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.work-layout { position: relative; min-width: 0; }
|
||||
:global(.work-dialog.el-dialog:not(.is-fullscreen)) { height: 88dvh; max-height: 88dvh; margin-top: 6dvh; }
|
||||
:global(.work-dialog .el-dialog__body) { display: flex; flex: 1; overflow: hidden; }
|
||||
:global(.work-dialog .app-dialog__body) { display: flex; flex: 1; min-width: 0; }
|
||||
.work-panel { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; overflow-wrap: anywhere; }
|
||||
.panel-header { flex: none; min-width: 0; font-size: var(--admin-font-body); font-weight: 400; }
|
||||
.panel-nav { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.panel-header h3 { font-size: var(--admin-font-title); margin: var(--admin-space-2) 0; }
|
||||
.scope-description { margin: var(--admin-space-1) 0; }
|
||||
.panel-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 var(--admin-space-1); }
|
||||
.panel-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); flex-wrap: wrap; flex: none; font-size: var(--admin-font-caption); }
|
||||
.origin-context { background: var(--el-fill-color-light); padding: var(--admin-space-3); }
|
||||
.work-row { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--admin-space-3); padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.work-row-action .el-button { white-space: normal; width: 100%; min-height: var(--admin-control-height); height: auto; padding: var(--admin-space-2); line-height: 20px; }
|
||||
.work-row-action :deep(.el-button > span) { min-width: 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; line-height: inherit; }
|
||||
.work-row-content { min-width: 0; }
|
||||
.item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-3); }
|
||||
.item-status { flex: none; color: var(--el-text-color-regular); background: var(--el-fill-color-light); border-radius: var(--admin-radius); padding: 0 var(--admin-space-2); font-size: var(--admin-font-caption); line-height: 24px; }
|
||||
.work-row-content p { margin: var(--admin-space-1) 0; color: var(--el-text-color-regular); font-size: var(--admin-font-body); line-height: 1.6; }
|
||||
.item-schedule, .item-context { display: flex; flex-wrap: wrap; column-gap: var(--admin-space-4); row-gap: 2px; }
|
||||
.item-date { color: var(--el-text-color-regular); font-variant-numeric: tabular-nums; }
|
||||
.appointment-work .work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-color-primary); background: transparent; font-weight: 600; }
|
||||
.work-row-content .work-warning { color: var(--el-color-warning-dark-2); border-left: 2px solid var(--el-color-warning-light-5); padding-left: var(--admin-space-2); }
|
||||
.work-pagination, .bucket-buttons { display: flex; align-items: center; gap: var(--admin-space-2); flex-wrap: wrap; }
|
||||
.bucket-buttons { margin-top: var(--admin-space-2); }
|
||||
.bucket-buttons button { font-size: var(--admin-font-caption); }
|
||||
.work-panel :deep(.work-form fieldset) { border: 0; margin: 0; padding: 0; min-width: 0; }.work-panel :deep(.work-form label:not(.el-form-item__label)) { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-bottom: var(--admin-space-5); font-size: var(--admin-font-caption); }.work-panel :deep(.work-form input:not(.el-input__inner)), .work-panel :deep(.work-form textarea:not(.el-textarea__inner)), .work-panel :deep(.work-form select) { box-sizing: border-box; width: 100%; font: inherit; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); }.work-panel :deep(.work-form input[type=checkbox]) { width: auto; align-self: flex-start; }.work-panel :deep(.work-form textarea:not(.el-textarea__inner)) { min-height: 64px; resize: vertical; }.work-panel :deep(.appointment-fields), .work-panel :deep(.appointment-fields__extra-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.arrival-field { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-block: var(--admin-space-5); }
|
||||
.arrival-field input { box-sizing: border-box; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); font: inherit; }
|
||||
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, KeepAlive, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
describe('calendar cache lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
})
|
||||
it('aborts reads when cached and reloads on return without creating background work polling', async () => {
|
||||
const active = ref(true)
|
||||
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div') }) })
|
||||
const wrapper = mount(host, { global: { stubs: { AdminPage: { template: '<main><slot /></main>' }, AppointmentCalendarPanel: { template: '<section />' }, AppDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||
const signal = vi.mocked(getCalendarSources).mock.calls[0]![1]!
|
||||
active.value = false; await flushPromises()
|
||||
expect(signal.aborted).toBe(true)
|
||||
active.value = true; await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,24 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
## 2026-10-19 四类日历恢复(当前结论)
|
||||
|
||||
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
|
||||
|
||||
| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
|
||||
|---|---|---|---|
|
||||
| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
|
||||
| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
|
||||
| 预约列表 | 新增、审核、删除 | 本轮明确取消通用入口 | 来源业务生成记录;不在日历另建预约或改状态 |
|
||||
| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
|
||||
| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
|
||||
| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
|
||||
|
||||
2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
|
||||
|
||||
发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
|
||||
|
||||
本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
|
||||
|
||||
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
|
||||
|
||||
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
|
||||
@@ -168,4 +187,9 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
|
||||
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
|
||||
|
||||
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
|
||||
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
|
||||
|
||||
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
|
||||
|
||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function render() { return mount(WorkBatchContinuation, { props: { batchKey: 'FBA_APPOINTMENT:40', labels: [{ customerId: 'customer-a', label: 'IN-A' }] }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></div>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('shows synchronization after a successful save and continues by reading without resubmitting', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('同步中')
|
||||
expect(wrapper.text()).toContain('无需重复提交')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['actual-key']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires a customer selection for multiple real work candidates and uses the selected resolver result', async () => {
|
||||
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: null, candidates: [{ customerId: 'customer-a', workItemKey: 'real-work-a', batchNo: 'AP-40' }, { customerId: 'customer-b', workItemKey: 'real-work-b', batchNo: 'AP-40' }] }).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: workDetail({ item: workItem({ workItemKey: 'current-work-a' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
|
||||
@@ -4,13 +4,15 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { resolveBatchWork, type BatchWorkResolution } from './workApi'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> }>()
|
||||
const emit = defineEmits<{ open: [key: string] }>()
|
||||
const resolution = ref<BatchWorkResolution | null>(null), error = ref<ApiErrorDetails | null>(null), loading = ref(false)
|
||||
const controller = new AbortController()
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load(customerId = props.customerId) {
|
||||
loading.value = true; error.value = null
|
||||
try { const value = await resolveBatchWork(props.batchKey, customerId, controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
|
||||
try { const value = await syncRead.read(() => resolveBatchWork(props.batchKey, customerId, controller.signal), controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
|
||||
catch (cause) { if (!controller.signal.aborted) error.value = getApiErrorDetails(cause, '当前批次下一步读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -19,7 +21,7 @@ onBeforeUnmount(() => controller.abort())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="当前批次续办">
|
||||
<p>正在定位当前批次的工作事项;已保存的内容无需重复提交。</p>
|
||||
<p role="status">{{ syncing ? '预约工作清单同步中,正在自动读取当前批次下一步;已保存内容无需重复提交。' : '正在定位当前批次的工作事项;已保存的内容无需重复提交。' }}</p>
|
||||
<LoadErrorState v-if="error" title="当前批次下一步读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="resolution && !resolution.detail && !error"><p>本批有多个客户,请选择本次继续办理的货物。</p><el-button v-for="(candidate, index) in resolution.candidates" :key="candidate.workItemKey" :disabled="loading" @click="load(candidate.customerId)">{{ candidate.batchNo || '—' }} · {{ labels?.find(row => row.customerId === candidate.customerId)?.label || `客户范围 ${index + 1}` }}</el-button><p v-if="!resolution.candidates.length">当前没有可定位的送仓事项,请核对服务端同步结果。</p></template>
|
||||
</section>
|
||||
|
||||
@@ -21,6 +21,21 @@ function render(detail: WorkDetail, initialAction?: WorkAction) {
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('navigates the server-selected transport next step outside the delivery write permissions', async () => {
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ nextAction: transport, allowedActions: [followAction] }) }))
|
||||
await flushPromises()
|
||||
await wrapper.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['transport-current', transport]])
|
||||
expect(wrapper.text()).not.toContain('操作资格已变化')
|
||||
expect(wrapper.find('[data-dispatch]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
const target = render(workDetail({ item: workItem({ workItemKey: 'transport-current', type: 'TRANSPORT', allowedActions: [], nextAction: null }) }), transport)
|
||||
await flushPromises()
|
||||
expect(target.find('[data-dispatch]').exists()).toBe(false)
|
||||
expect(target.emitted('open')).toBeUndefined()
|
||||
target.unmount()
|
||||
})
|
||||
it('stays on the saved item and offers only the newly fetched primary next step', async () => {
|
||||
const wrapper = render(workDetail(), followAction); await flushPromises()
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
|
||||
@@ -17,6 +17,7 @@ import WorkReceivingPanel from './WorkReceivingPanel.vue'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import type { FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
|
||||
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
|
||||
@@ -28,9 +29,11 @@ const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | nu
|
||||
const receivingKey = ref<string | null>(null)
|
||||
const continuation = ref<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> } | null>(null)
|
||||
let controller: AbortController | null = null, request = 0
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load() {
|
||||
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
|
||||
try { const result = await getWorkDetail(props.itemKey, controller.signal); if (generation === request) detail.value = result }
|
||||
const signal = controller.signal
|
||||
try { const result = await syncRead.read(() => getWorkDetail(props.itemKey, signal), signal); if (generation === request) detail.value = result }
|
||||
catch (cause) { if (generation === request) error.value = getApiErrorDetails(cause, '工作事项读取失败') }
|
||||
finally { if (generation === request) loading.value = false }
|
||||
}
|
||||
@@ -60,7 +63,9 @@ const secondaryActions = computed(() => detail.value?.item.allowedActions.filter
|
||||
async function choose(next: WorkAction) {
|
||||
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
|
||||
actionNotice.value = ''
|
||||
if (!actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
// 跨事项的主要下一步只用于导航,目标面板会重新读取并校验自己的写入资格。
|
||||
const linkedNext = next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey && detail.value.item.nextAction && sameAction(next, detail.value.item.nextAction)
|
||||
if (!linkedNext && !actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
saveCompleted.value = false
|
||||
if (next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey) { emit('open', next.targetWorkItemKey, next); return }
|
||||
const path = actionRoute(next, detail.value.references, detail.value.item)
|
||||
@@ -102,7 +107,7 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
<template>
|
||||
<div :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取当前事项与操作资格…</p>
|
||||
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
|
||||
<template v-if="detail">
|
||||
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
|
||||
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
|
||||
|
||||
@@ -1,24 +1,76 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
|
||||
import { getReceivingContext, type ReceivingContext } from './receivingApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
function receiving(batchKey: string, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', batchKey).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'v1', vehicles, blockReasons: [] }, executionScope: { version: 'e1', members: [] }, allowedActions: [], blockReasons: [] } }
|
||||
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); access.allowed = true
|
||||
vi.mocked(getReceivingContext).mockImplementation(async batchKey => receiving(batchKey))
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([])
|
||||
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
|
||||
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
|
||||
})
|
||||
it('restores the original FBA dispatch after remount and opens trip preparation without any draft or creation', async () => {
|
||||
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', overseasWarehouseId: 'origin-1', packageCount: 4, appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
|
||||
for (let mountNumber = 0; mountNumber < 2; mountNumber++) {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('DS-EXISTING')
|
||||
expect(wrapper.text()).not.toContain('创建本客户排车单')
|
||||
await button(wrapper, '继续安排本批车辆').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-EXISTING'] })
|
||||
await button(wrapper, '查看原排车单').trigger('click')
|
||||
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches?dispatchNo=DS-EXISTING']])
|
||||
wrapper.unmount()
|
||||
}
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(createDispatchDraftContainer).not.toHaveBeenCalled()
|
||||
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
it('fails closed when transport relations cannot be read', async () => {
|
||||
vi.mocked(getReceivingContext).mockRejectedValue(new Error('unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('创建本客户排车单')
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not offer remaining-cargo creation when the original dispatch no longer owns this batch', async () => {
|
||||
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
|
||||
expect(wrapper.text()).not.toContain('创建本客户排车单')
|
||||
expect(wrapper.text()).not.toContain('核对本批剩余可排实物')
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('loads remaining eligible cargo only after an explicit choice while retaining the existing dispatch', async () => {
|
||||
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises()
|
||||
expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith('FBA_APPOINTMENT:1')
|
||||
expect(wrapper.text()).toContain('DS-EXISTING')
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
|
||||
@@ -6,8 +6,10 @@ import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchD
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import { getReceivingContext } from './receivingApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
|
||||
@@ -15,10 +17,12 @@ const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
|
||||
const sharedTripOpen = ref(false), sharedTripBusy = ref(false)
|
||||
const existingDispatches = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), arrangingRemaining = ref(false)
|
||||
const existingReady = ref(false)
|
||||
const selectionBaseline = ref('[]')
|
||||
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
|
||||
const writable = computed(() => props.canWrite && readable.value && (!existingDispatches.value.length || existingReady.value) && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
|
||||
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
|
||||
function reasons(row: BatchDispatchCandidate) {
|
||||
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
|
||||
@@ -28,9 +32,24 @@ const chosen = computed(() => rows.value.filter(row => selected.value.includes(r
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== selectionBaseline.value && !result.value), computed(() => saving.value || !!pending.value))
|
||||
async function load() {
|
||||
if (!readable.value || pending.value) return
|
||||
loading.value = true; error.value = null
|
||||
loading.value = true; error.value = null; existingReady.value = false
|
||||
try {
|
||||
contexts.value = await Promise.all([...new Set(props.batchKeys)].map(getBatchDispatchCandidates))
|
||||
// 从服务端当前运输关系恢复排车单;读取失败时禁止退回创建流程。
|
||||
const batches = [...new Set(props.batchKeys)]
|
||||
const receiving = await Promise.all(batches.map(batch => getReceivingContext(batch)))
|
||||
const references: typeof existingDispatches.value = []
|
||||
receiving.forEach((context, index) => {
|
||||
const batchKey = batches[index]!
|
||||
if (context.batch.batchKey !== batchKey) throw new ApiResponseError('运输关系与当前预约批次不一致,请重新读取', 'WORK_DISPATCH_MISMATCH')
|
||||
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
|
||||
if (!vehicle.dispatchNo) throw new ApiResponseError('本批已有运输安排但缺少排车单号,请核对原车次', 'WORK_DISPATCH_MISMATCH')
|
||||
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
|
||||
}
|
||||
})
|
||||
existingDispatches.value = references
|
||||
contexts.value = []; selected.value = []; selectionBaseline.value = '[]'
|
||||
if (references.length && !arrangingRemaining.value) return
|
||||
contexts.value = await Promise.all(batches.map(batch => getBatchDispatchCandidates(batch)))
|
||||
const eligible = rows.value.filter(row => !reasons(row).length && row.packageCount != null && row.packageCount > 0)
|
||||
selected.value = props.batchKeys.length === 1 && eligible.length <= capacity.value && new Set(eligible.map(row => row.customerId)).size === 1 ? eligible.map(row => row.planPalletId) : []
|
||||
selectionBaseline.value = JSON.stringify(selected.value)
|
||||
@@ -81,18 +100,22 @@ async function refreshRejected() {
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; await load() } }
|
||||
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; arrangingRemaining.value = true; await load() } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="按预约批次排车">
|
||||
<h3>按批次选择真实托盘</h3><p>单客户单批入口仅预选本批当前合格实物;每次选择同一客户创建排车单,再安排车辆和司机。逐来源数量由服务端候选确定。</p>
|
||||
<el-button v-if="batchKeys.length === 1 && !result" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
|
||||
<h3>{{ existingDispatches.length ? '沿用本批已有排车单' : '按批次选择真实托盘' }}</h3><p>已有排车单沿用原实物安排车辆;只有尚未分配的实物才需要创建排车单。</p>
|
||||
<el-button v-if="batchKeys.length === 1 && !result && !existingDispatches.length" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result && !existingDispatches.length">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、客户与实物来源…</p>
|
||||
<template v-if="existingDispatches.length && !loading && !error && !result">
|
||||
<WorkExistingDispatchForm :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<el-button v-if="!arrangingRemaining && writable" @click="continueArranging">核对本批剩余可排实物</el-button>
|
||||
</template>
|
||||
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" :disabled="sharedTripBusy" @click="prepareOpen = true">继续安排本批车辆</el-button><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list') && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" @click="sharedTripOpen = true">与同仓其他排车单共车</el-button><WorkSharedTripForm v-if="sharedTripOpen" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="sharedTripBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button :disabled="sharedTripBusy" @click="continueArranging">继续安排其他实物</el-button></template>
|
||||
<template v-else-if="readable"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<template v-else-if="readable && !loading && !error && (!existingDispatches.length || arrangingRemaining && existingReady)"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
|
||||
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
|
||||
</template>
|
||||
|
||||
@@ -5,8 +5,22 @@ import { getDispatch } from '@/modules/overseas-warehouse'
|
||||
const state = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
function render() { return mount(WorkExistingDispatchForm, { props: { dispatches: [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }], canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
|
||||
it.each(['customer-b', undefined])('rejects FBA dispatch references outside the exact batch customer: %s', async customerId => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
|
||||
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: 'DS-ORIGINAL', customerId }]); await flushPromises()
|
||||
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reads shared dispatch once and verifies every FBA batch reference', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: ['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, batchNo: `AP-${id}`, customerId: 'customer-a', sources: [] })) })
|
||||
const wrapper = render(['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, dispatchNo: 'DS-ORIGINAL', customerId: 'customer-a' }))); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
|
||||
|
||||
@@ -6,21 +6,27 @@ import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string }>; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string] }>()
|
||||
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }>; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; ready: [valid: boolean] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<DispatchSummaryDto[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit'))
|
||||
async function load() {
|
||||
if (!readable.value) return
|
||||
loading.value = true; error.value = null
|
||||
loading.value = true; error.value = null; emit('ready', false)
|
||||
try {
|
||||
rows.value = await Promise.all(props.dispatches.map(async reference => {
|
||||
const row = await getDispatch(reference.dispatchNo)
|
||||
if (`DISPATCH:${row.id}` !== reference.batchKey || row.dispatchNo !== reference.dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
rows.value = await Promise.all([...new Set(props.dispatches.map(reference => reference.dispatchNo))].map(async dispatchNo => {
|
||||
const row = await getDispatch(dispatchNo)
|
||||
// 预约批次必须仍由这张原排车单持有同一客户的分配,不能仅凭展示业务号续办。
|
||||
const matches = props.dispatches.filter(reference => reference.dispatchNo === dispatchNo).every(reference =>
|
||||
reference.batchKey.startsWith('FBA_APPOINTMENT:')
|
||||
? !!reference.customerId && row.status !== 'CANCELLED' && row.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId)
|
||||
: `DISPATCH:${row.id}` === reference.batchKey)
|
||||
if (!matches || row.dispatchNo !== dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
return row
|
||||
}))
|
||||
emit('ready', true)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '原排车单读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -29,7 +35,7 @@ function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false;
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>客户仓批次沿用已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
|
||||
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>沿用本批已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
|
||||
<p v-if="!readable">查看原排车单需要排车列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">继续安排本批车辆</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectContainerSchedule } from './schedule'
|
||||
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
|
||||
const range = { from: '2026-11-01', to: '2026-12-01' }
|
||||
const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
describe('four-type schedule projection', () => {
|
||||
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
|
||||
})
|
||||
it('adds two local calendar days across months without creating a time', () => {
|
||||
const events = projectContainerSchedule(sources, [warehouse], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
|
||||
})
|
||||
it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
|
||||
const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
|
||||
expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
|
||||
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
|
||||
})
|
||||
it('keeps manually moved containers visible when their arrival is outside this month', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.date).toBe('2026-11-08')
|
||||
})
|
||||
it('does not replace the planned date with PDA actual arrival', () => {
|
||||
const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
|
||||
expect(event.date).toBe('2026-11-05')
|
||||
expect(event.canEdit).toBe(false)
|
||||
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
|
||||
})
|
||||
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
|
||||
expect(events.map(row => row.date)).toEqual([null, null])
|
||||
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
|
||||
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
|
||||
})
|
||||
it('does not invent default plans when the authoritative warehouse query failed', () => {
|
||||
expect(projectContainerSchedule(sources, null, range, true)).toEqual([])
|
||||
})
|
||||
it('uses the actual return date after completion while preserving the deadline in details', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [], emptyReturns: [{ containerBookingId: '1', containerNo: 'TEST-CONT', fleetSupplierName: null, actualEmptyReturnAt: '2026-11-04T10:00:00', emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-11-02T10:00:00' } }] }, [], range, true)
|
||||
expect(events[0]).toMatchObject({ type: 'return', date: '2026-11-04', subtitle: '实际还柜' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { addDays } from './calendar'
|
||||
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
|
||||
|
||||
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
|
||||
export type ScheduleType = typeof scheduleTypes[number]
|
||||
export const scheduleLabels: Record<ScheduleType, string> = { arrival: '到港', dispatch: '排车', return: '还柜', warehouse: '计划到仓' }
|
||||
export interface ScheduleEvent {
|
||||
key: string
|
||||
type: ScheduleType
|
||||
date: string | null
|
||||
time: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
sourcePath?: string
|
||||
warehouse?: WarehouseArrival
|
||||
canEdit?: boolean
|
||||
}
|
||||
const inRange = (date: string | null, range: ScheduleRange) => !date || date >= range.from && date < range.to
|
||||
const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '—'
|
||||
|
||||
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
|
||||
const events: ScheduleEvent[] = []
|
||||
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
|
||||
for (const row of sources.containers) {
|
||||
const label = row.containerNo || row.cabinetNo || '—'
|
||||
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
|
||||
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
|
||||
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
|
||||
const date = row.arrivalAt?.slice(0, 10) || null
|
||||
if (inRange(date, range)) events.push({ key: `arrival-${row.containerBookingId}`, type: 'arrival', date,
|
||||
time: row.arrivalAt?.slice(11, 16) || '', title: `${label} 到港`, sourcePath,
|
||||
subtitle: row.arrivalAt ? row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' : '到港日期未定',
|
||||
fields: [...fields, { label: row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港', value: timestamp(row.arrivalAt) }] })
|
||||
// 读取失败时不能用默认值遮掉无法读取的人工安排。
|
||||
if (warehouses === null) continue
|
||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||
warehouseMap.delete(row.containerBookingId)
|
||||
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
|
||||
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
|
||||
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
||||
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
|
||||
canEdit: warehouse?.canEdit === true,
|
||||
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
|
||||
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
||||
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
||||
}
|
||||
// 人工改期可能跨月,与当前月份的到港记录无关。
|
||||
for (const row of warehouseMap.values()) {
|
||||
const date = row.plannedAt?.slice(0, 10) || null
|
||||
if (!date || !inRange(date, range)) continue
|
||||
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
|
||||
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
|
||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
|
||||
{ label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
|
||||
}
|
||||
for (const row of sources.emptyReturns) {
|
||||
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
|
||||
if (!at || !inRange(at.slice(0, 10), range)) continue
|
||||
events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
|
||||
title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
|
||||
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
|
||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
|
||||
{ label: '还柜截止', value: timestamp(row.emptyReturnWarning.deadlineAt) }, { label: '实际还柜', value: timestamp(row.actualEmptyReturnAt) }] })
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface CalendarContainer {
|
||||
containerBookingId: string
|
||||
cabinetNo: string | null
|
||||
containerNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
destinationPortName: string | null
|
||||
destinationWarehouseCode: string | null
|
||||
arrivalAt: string | null
|
||||
arrivalType: 'ACTUAL' | 'ESTIMATED'
|
||||
}
|
||||
export interface CalendarReturn {
|
||||
containerBookingId: string
|
||||
containerNo: string | null
|
||||
fleetSupplierName: string | null
|
||||
actualEmptyReturnAt: string | null
|
||||
emptyReturnWarning: { status: string; deadlineAt: string | null }
|
||||
}
|
||||
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
|
||||
export interface WarehouseArrival {
|
||||
sourceContainerBookingId: string
|
||||
containerNo: string | null
|
||||
warehouseName: string | null
|
||||
warehouseCode: string | null
|
||||
plannedAt: string | null
|
||||
actualAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
canEdit: boolean
|
||||
}
|
||||
export interface ScheduleRange { from: string; to: string }
|
||||
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
|
||||
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
|
||||
))
|
||||
}
|
||||
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
|
||||
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
|
||||
))
|
||||
}
|
||||
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
|
||||
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
|
||||
return unwrapApiResponse(await http.put<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(containerId)}/expected-arrival`, body,
|
||||
{ headers: { 'Idempotency-Key': key } },
|
||||
))
|
||||
}
|
||||
@@ -7,6 +7,13 @@ function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'Amer
|
||||
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
}
|
||||
describe('location-based trip calendar', () => {
|
||||
it('counts a shared trip once and excludes cancelled arrangements', () => {
|
||||
const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z')
|
||||
const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
|
||||
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
|
||||
expect(result.events).toHaveLength(1)
|
||||
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' })
|
||||
})
|
||||
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
|
||||
const localRange = {from:'2026-09-28',to:'2026-11-09'}
|
||||
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
|
||||
export interface TripCalendarEntry {
|
||||
sourceType: 'TRIP_PLAN'
|
||||
@@ -54,9 +54,10 @@ function localStamp(value: string | null, zone: string | null) {
|
||||
} catch { return null }
|
||||
}
|
||||
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
|
||||
const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
|
||||
for (const row of result.records) {
|
||||
for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) {
|
||||
if (row.status === 'CANCELLED') continue
|
||||
// Executed work stays on its actual local date even if the plan changes later.
|
||||
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
|
||||
if (row.originCountryCode !== 'US' || !occurrence) {
|
||||
@@ -65,8 +66,8 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
||||
if (occurrence.date < range.from || occurrence.date >= range.to) continue
|
||||
const actual = localStamp(row.departedAt, row.originTimezone)
|
||||
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认',
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
|
||||
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
|
||||
fields: [
|
||||
{ label: '车次状态', value: labels[row.status] || '待核实' },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const pending = () => new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409, undefined, 'request-sync', 'trace-sync')
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('waits only for the full-work synchronization guard and returns the fresh result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValueOnce(pending()).mockResolvedValue('fresh')
|
||||
const result = reader.read(load, new AbortController().signal)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(reader.syncing.value).toBe(true)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(await result).toBe('fresh')
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
})
|
||||
it.each([new ApiResponseError('事项版本已变化', 'A0409', 409), new ApiResponseError('无权查看', 'A0403', 403), new Error('network')])('does not retry other failures: %s', async error => {
|
||||
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
|
||||
await expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
})
|
||||
it('stops waiting at the limit and preserves diagnostic IDs on the original error', async () => {
|
||||
vi.useFakeTimers()
|
||||
const error = pending(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
|
||||
const result = expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
await result
|
||||
expect(load).toHaveBeenCalledTimes(16)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
expect(error.traceId).toBe('trace-sync')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
it('cancels a waiting read without sending another request', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(pending())
|
||||
const result = expect(reader.read(load, controller.signal)).rejects.toHaveProperty('name', 'AbortError')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
controller.abort()
|
||||
await result
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
|
||||
// 只等待工作清单同步守卫;版本冲突、权限失败和写请求均不重试。
|
||||
export function useWorkSyncRead() {
|
||||
const syncing = ref(false)
|
||||
let generation = 0
|
||||
async function read<T>(load: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
const current = ++generation
|
||||
syncing.value = false
|
||||
try {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
signal.throwIfAborted()
|
||||
try { return await load() }
|
||||
catch (cause) {
|
||||
const error = getApiErrorDetails(cause)
|
||||
if (error.code !== 'A0409' || error.message !== '预约批次或运输关联已变化,服务端正在协调完整工作清单' || attempt >= 15) throw cause
|
||||
if (current === generation) syncing.value = true
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => { clearTimeout(timer); reject(signal.reason) }
|
||||
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 2000)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
if (signal.aborted) { signal.removeEventListener('abort', abort); abort() }
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally { if (current === generation) syncing.value = false }
|
||||
}
|
||||
return { syncing, read }
|
||||
}
|
||||
@@ -14,3 +14,7 @@
|
||||
- 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。
|
||||
- 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。
|
||||
- CSV 下载只接受真实 Blob;JSON 错误 Blob 转为带 `requestId/traceId` 的业务错误,不保存为文件。
|
||||
|
||||
2026-10-17:仓储转运上门取货使用 `fleet-transfer-pickup-v1.yaml` 新接口,需部署Lastleg及 `baiyue_lastleg` 结构增量后生效。本轮只操作本机,不据本机验收推定远端已部署;人工页面视觉待验收。
|
||||
|
||||
2026-10-17 资源占用辅助查询:`GET /fleet/trips/{tripNo}/resource-occupancy`,按VEHICLE/DRIVER和本次最多100个ID批量读取;本车资源可保留、其他车次禁选。沿trip:list平台授权;只读、不预留,分配写入口再次锁定复验。前后端需一起更新,无新增数据库结构。
|
||||
|
||||
@@ -148,3 +148,17 @@
|
||||
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
|
||||
|
||||
2026-10-10 拼车简化收口:调度默认待我处理,销售默认可拼车次;登记仍为1080px居中弹框,默认只展开客户和卸货站,已知货量与确认前资料分区展开;只读跟进仅突出当前有权限的主要动作,其他操作与关联单据收起。沿原请求、权限与真实作业节点;本次视觉变更待1920×1080、100%缩放人工截图确认。
|
||||
|
||||
## 仓储转运上门取货(2026-10-17)
|
||||
|
||||
依据本轮用户确认的仓储转运与车辆排车结合方案。
|
||||
|
||||
| 页面 | 承载 | 业务与验收边界 |
|
||||
|---|---|---|
|
||||
| 车辆排车 | 原列表增加待取货页签,服务端分页 | 同仓选择原转运单;780px居中安排弹框,不新增业务单 |
|
||||
| 车次详情 | 原独立详情内展示逐票取货进度 | 共用车辆司机分配;发车、送仓、运输结案使用确认框;680px居中取货弹框填写实提和凭证 |
|
||||
| 平台尾程详情、客户端转运详情 | 原单详情内嵌取货进度 | 只显示本票车辆司机、实提与到仓及凭证;不替代仓内测量状态 |
|
||||
|
||||
取货用途隐藏出库关联、外接预订、装载和余位发布。请求结果未知保留原键与载荷重试,阻止刷新和改派丢失请求。本轮类型检查、构建、单测及本机HTTP验收分别记录,尚未进行人工页面截图视觉验收。
|
||||
|
||||
2026-10-17 车次资源选择修正:沿原详情分配表单及远程下拉框,每次目录搜索后批量读取当前候选的实际占用;显示空闲、当前车次或占用车次号,别车占用不可选。资格与占用分别显示,保存由原锁定分配复验,不在浏览器从当前分页车次推断空闲。占用查询失败不展示未知候选为可用。单测与本机HTTP分别验证,未做截图视觉验收。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeTripDetail } from './trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
export interface PickupSource { customerId: string; transferNo: string }
|
||||
export interface PickupCandidate extends PickupSource { lastlegOrderNo: string; warehouseId: string; warehouseCode?: string | null; warehouseName?: string | null; pickupAddress: string; contactName: string; contactPhone: string; packageCount: number; expectedArrivalAt: string | null }
|
||||
export interface PickupEvidence { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface PickupTask extends Omit<PickupCandidate, 'expectedArrivalAt'> {
|
||||
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
|
||||
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null; evidence: PickupEvidence[]
|
||||
}
|
||||
export interface PickupPlan { sources: PickupSource[]; plannedDepartureAt: string; expectedDriverEndAt: string; remark?: string; idempotencyKey: string }
|
||||
export interface PickupCommand { recordVersion: number; idempotencyKey: string; reason: string }
|
||||
export interface PickupConfirmation { recordVersion: number; idempotencyKey: string; actualPackages: number; evidenceFileIds: string[]; remark?: string }
|
||||
export const pickupStatusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', WAITING: '待安排取货', ASSIGNED: '已安排取货', PICKED: '已取货', ARRIVED: '已送仓' }
|
||||
const base = '/platform/v1/last-leg/fleet/pickups'
|
||||
const tripPath = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}`
|
||||
const taskPath = (tripNo: string, row: PickupSource) => `${tripPath(tripNo)}/tasks/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}`
|
||||
type Page = { records: PickupCandidate[]; total: string | number }
|
||||
type WireDetail = Parameters<typeof normalizeTripDetail>[0]
|
||||
|
||||
/** 待取货列表始终以服务端资格和总数为准。 */
|
||||
export async function listPickupCandidates(page: number, size: number, keyword?: string) {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<Page>, ApiEnvelope<Page>>(base, { params: { page, size, keyword } }))
|
||||
return { records: value.records, total: Number(value.total) }
|
||||
}
|
||||
/** 安排成功返回原有车次详情。 */
|
||||
export async function createPickupTrip(body: PickupPlan): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(base, body)))
|
||||
}
|
||||
/** 车次成员一次读取,避免逐行加载。 */
|
||||
export async function getPickupTasks(tripNo: string): Promise<PickupTask[]> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${tripPath(tripNo)}/tasks`))
|
||||
}
|
||||
/** 运输发车与结案沿取货专用规则执行。 */
|
||||
export async function commandPickupTrip(tripNo: string, action: 'depart' | 'complete', body: PickupCommand): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${tripPath(tripNo)}/${action}`, body)))
|
||||
}
|
||||
/** 取货和送仓逐票确认,保留请求键以支持未知结果重试。 */
|
||||
export async function commandPickupTask(tripNo: string, row: PickupSource, action: 'picked' | 'arrive', body: PickupCommand | PickupConfirmation): Promise<PickupTask[]> {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${taskPath(tripNo, row)}/${action}`, body))
|
||||
}
|
||||
/** 文件上传绑定本票与当前车次。 */
|
||||
export async function uploadPickupEvidence(tripNo: string, row: PickupSource, file: File): Promise<PickupEvidence> {
|
||||
const body = new FormData(); body.append('file', file)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<PickupEvidence>, ApiEnvelope<PickupEvidence>>(`${taskPath(tripNo, row)}/files`, body))
|
||||
}
|
||||
/** 本票凭证通过授权入口读取,永不使用公共文件直链。 */
|
||||
export async function downloadPickupEvidence(row: PickupSource, fileId: string): Promise<Blob> {
|
||||
return http.get<Blob, Blob>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(row.transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId: row.customerId }, responseType: 'blob' })
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import {
|
||||
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
|
||||
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, getTripResourceOccupancy, bindTripDispatch, unbindTripDispatch, cancelTrip,
|
||||
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
|
||||
} from './trips'
|
||||
|
||||
@@ -26,6 +26,15 @@ const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
|
||||
describe('fleet trip API contract', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('batches occupancy queries without rounding long resource identifiers', async () => {
|
||||
const occupancy = [{ resourceId: longId, occupiedTripNo: 'TR-OTHER', available: false }]
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(occupancy))
|
||||
expect(await getTripResourceOccupancy('TR/TEST', 'DRIVER', [longId, '9007199254740997'])).toEqual(occupancy)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/resource-occupancy`, { params: { kind: 'DRIVER', resourceIds: `${longId},9007199254740997` } })
|
||||
expect(await getTripResourceOccupancy('TR/TEST', 'VEHICLE', [])).toEqual([])
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses one paged request and keeps long identifiers as exact strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' }))
|
||||
const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId }
|
||||
|
||||
@@ -52,7 +52,7 @@ const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer
|
||||
const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) })
|
||||
const batchReferences = (values: TripBinding['appointmentBatches']) => values.map(reference => ({ ...reference, sources: reference.sources.map(source => ({ ...source, packageCount: integer(source.packageCount) })) }))
|
||||
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), appointmentBatches: batchReferences(value.appointmentBatches), review: review(value.review) })
|
||||
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
|
||||
export const normalizeTripDetail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
|
||||
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
|
||||
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
|
||||
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })
|
||||
@@ -71,13 +71,19 @@ export async function listTrips(query: TripQuery): Promise<PageResult<FleetTrip>
|
||||
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
|
||||
}
|
||||
export async function getTrip(tripNo: string): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
|
||||
}
|
||||
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
|
||||
}
|
||||
export const createTrip = (body: TripDraft) => postDetail(base, body)
|
||||
export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body)
|
||||
export interface TripResourceOccupancy { resourceId: string; occupiedTripNo: string | null; available: boolean }
|
||||
/** 当前下拉候选批量查询实际占用,长 ID 始终按字符串传递。 */
|
||||
export async function getTripResourceOccupancy(tripNo: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]): Promise<TripResourceOccupancy[]> {
|
||||
if (!resourceIds.length) return []
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<TripResourceOccupancy[]>, ApiEnvelope<TripResourceOccupancy[]>>(`${path(tripNo)}/resource-occupancy`, { params: { kind, resourceIds: resourceIds.join(',') } }))
|
||||
}
|
||||
export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body)
|
||||
export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body)
|
||||
export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body)
|
||||
@@ -86,7 +92,7 @@ export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason
|
||||
export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body)
|
||||
export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body)
|
||||
export async function updateTripPlan(tripNo: string, body: TripPlan): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
|
||||
}
|
||||
const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) })
|
||||
export async function getTripCapacityPlan(tripNo: string): Promise<TripCapacityPlanning> {
|
||||
@@ -121,5 +127,5 @@ export async function listTripDispatchCandidates(tripNo: string, keyword?: strin
|
||||
}
|
||||
export interface TripPreparation { tripNo: string | null; recordVersion: number | null; plan: TripDraft | null; vehicleId: string | null; driverId: string | null; dispatchNos: string[]; idempotencyKey: string }
|
||||
export async function prepareTrip(body: TripPreparation): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import PickupDemandPanel from './PickupDemandPanel.vue'
|
||||
import { listPickupCandidates, createPickupTrip, type PickupCandidate } from '../api/pickups'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
const state = vi.hoisted(() => ({ push: vi.fn(), write: true }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }), onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
|
||||
vi.mock('../api/pickups', () => ({ listPickupCandidates: vi.fn(), createPickupTrip: vi.fn() }))
|
||||
const rows: PickupCandidate[] = [1, 2].map(i => ({ customerId: `C${i}`, transferNo: `WT-${i}`, lastlegOrderNo: `LL-${i}`, warehouseId: '9007199254740999', pickupAddress: `TEST Ontario ${i}`, contactName: '取货验收员', contactPhone: '000', packageCount: 5, expectedArrivalAt: null }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount() {
|
||||
const wrapper = shallowMount(PickupDemandPanel, { global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total'], emits: ['selection-change'], template: '<div><slot name="toolbar-actions"/><slot name="notices"/></div>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/></div>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElDatePicker: { name: 'ElDatePicker', props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' },
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); state.write = true; vi.mocked(listPickupCandidates).mockResolvedValue({ records: rows, total: 42 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('uses server total and rejects combining different destination warehouses', async () => {
|
||||
const wrapper = mount(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(42)
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', [rows[0], { ...rows[1], warehouseId: 'OTHER' }]); await flushPromises()
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(false)
|
||||
expect(createPickupTrip).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps exact source identities and the same create request after an unknown network result', async () => {
|
||||
const wrapper = mount(); await flushPromises()
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', rows); await flushPromises(); await wrapper.find('button').trigger('click')
|
||||
const dates = wrapper.findAllComponents({ name: 'ElDatePicker' })
|
||||
dates[0]!.vm.$emit('update:modelValue', '2026-10-17T08:00:00Z'); dates[1]!.vm.$emit('update:modelValue', '2026-10-18T08:00:00Z'); await flushPromises()
|
||||
vi.mocked(createPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ trip: { tripNo: 'TR-TEST' } } as TripDetail)
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const request = vi.mocked(createPickupTrip).mock.calls[0]![0]
|
||||
expect(request.sources).toEqual(rows.map(({ customerId, transferNo }) => ({ customerId, transferNo })))
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(vi.mocked(createPickupTrip).mock.calls[1]![0]).toBe(request)
|
||||
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
|
||||
})
|
||||
it('does not show scheduling to read-only users', async () => {
|
||||
state.write = false; const wrapper = mount(); await flushPromises()
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPickupCandidates, createPickupTrip, type PickupCandidate, type PickupPlan } from '../api/pickups'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const rows = ref<PickupCandidate[]>([]), selected = ref<PickupCandidate[]>([]), page = ref(1), total = ref(0), loading = ref(false)
|
||||
const filters = ref<QueryModel>({ keyword: '' }), applied = ref(''), error = ref(''), open = ref(false), busy = ref(false)
|
||||
const departure = ref(''), end = ref(''), remark = ref(''), snapshot = ref<PickupCandidate[]>([]), pending = ref<PickupPlan | null>(null)
|
||||
let sequence = 0
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'selection', type: 'selection', width: 45 }, { key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
|
||||
{ key: 'lastlegOrderNo', prop: 'lastlegOrderNo', label: '尾程单号', minWidth: 200 },
|
||||
{ key: 'warehouseId', prop: 'warehouseId', label: '入仓仓库', minWidth: 165, slotName: 'warehouse' },
|
||||
{ key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 280 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 150 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '期望入仓时间', minWidth: 175 },
|
||||
]
|
||||
/** 分页或条件变化时清除旧选择,迟到响应不能覆盖新页。 */
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = ''; selected.value = []
|
||||
try { const value = await listPickupCandidates(page.value, 20, applied.value || undefined); if (current === sequence) { rows.value = value.records; total.value = value.total } }
|
||||
catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '待取货需求加载失败').message } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
/** 查询只采用明确提交的条件。 */
|
||||
function search() { applied.value = String(filters.value.keyword || '').trim(); if (page.value !== 1) page.value = 1; else void load() }
|
||||
/** 安排弹框固定本次选单快照。 */
|
||||
function arrange() {
|
||||
if (!selected.value.length) { ElMessage.warning('请勾选待取货订单'); return }
|
||||
if (new Set(selected.value.map(row => row.warehouseId)).size !== 1) { ElMessage.warning('同车取货必须送往同一海外仓'); return }
|
||||
snapshot.value = [...selected.value]; pending.value = null; departure.value = ''; end.value = ''; remark.value = ''; error.value = ''; open.value = true
|
||||
}
|
||||
/** 网络结果未知时沿原键原内容重试,业务错误后允许修正输入。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!pending.value && (!departure.value || !end.value)) { ElMessage.warning('请填写取货发车时间和预计任务结束时间'); return }
|
||||
pending.value ||= { sources: snapshot.value.map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date(departure.value).toISOString(), expectedDriverEndAt: new Date(end.value).toISOString(), remark: remark.value.trim(), idempotencyKey: crypto.randomUUID() }
|
||||
busy.value = true
|
||||
try { const value = await createPickupTrip(pending.value); open.value = false; pending.value = null; await router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: value.trip.tripNo } }) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '安排取货未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
/** 未知提交结果必须保留原请求,避免关闭后重复建车次。 */
|
||||
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先重试原安排请求,确认结果后再关闭'); return }; done() }
|
||||
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先确认本次安排结果'); return false }; return true })
|
||||
watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<DataTable v-model:page="page" :page-size="20" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="transferNo" empty-text="暂无已审核的待取货订单" @selection-change="selected = $event">
|
||||
<template #filters><QueryToolbar ariaLabel="待取货需求查询" v-model="filters" :fields="[{ key: 'keyword', type: 'input', label: '关键词', placeholder: '转运单号 / 取货地址', width: 300 }]" @search="search" @reset="search" /></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" :disabled="!selected.length" @click="arrange">安排取货({{ selected.length }})</el-button></template>
|
||||
<template #notices><LoadErrorState v-if="error && !open" title="待取货需求加载失败" :message="error" @retry="load" /></template>
|
||||
<template #warehouse="{ row }">{{ row.warehouseName || '海外仓' }} · {{ row.warehouseCode || row.warehouseId }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
|
||||
<p>已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}</p>
|
||||
<el-form label-position="top" class="pickup-plan-form" :disabled="busy || !!pending">
|
||||
<el-form-item label="计划取货发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="司机预计任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="调度备注"><el-input v-model="remark" maxlength="1000" placeholder="请输入调度备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.pickup-plan-form{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}.pickup-plan-form :deep(.el-date-editor){width:100%}.pickup-plan-form>.el-form-item:last-child{grid-column:1/-1}</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import TripAssignmentPanel from './TripAssignmentPanel.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { assignTrip, getTripResourceOccupancy } from '../api/trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { DriverDetail } from '../driverTypes'
|
||||
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() }))
|
||||
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() }))
|
||||
vi.mock('../api/trips', () => ({ assignTrip: vi.fn(), getTripResourceOccupancy: vi.fn() }))
|
||||
const vehicle: VehicleDetail = { id: '9007199254740999', plateNumber: 'TEST-FREE', model: '26ft', maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45, fuelConsumptionPer100Km: 20, mileageKm: 0, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: '1', updatedAt: '' }
|
||||
const driver: DriverDetail = { id: '9007199254740998', driverNo: 'DR-TEST', details: { name: '验收司机', phone: '000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-12-31', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '1', recordVersion: 0, updatedBy: '1', updatedAt: '', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-12-31' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null }
|
||||
const trip: TripDetail = { trip: { tripNo: 'TR-CURRENT', status: 'PLANNED', warehouseId: '1', warehouseCode: 'WH', warehouseName: '仓库', vehicleId: null, vehiclePlate: null, driverId: null, driverName: null, driverPhone: null, maxPalletSpaces: null, maxLoadKg: null, maxVolumeCbm: null, plannedDepartureAt: '2026-10-18T01:00:00Z', expectedDriverEndAt: '2026-10-18T09:00:00Z', departedAt: null, completedAt: null, stops: [], remark: null, recordVersion: 1, updatedAt: '', vehicleRelease: null, driverRelease: null }, driverQualification: null, dispatches: [], loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }, previousDispatches: [], appointmentHistoryUnavailableReason: null }
|
||||
function mount() {
|
||||
return shallowMount(TripAssignmentPanel, { props: { detail: trip }, global: { stubs: {
|
||||
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElSelect: { name: 'ElSelect', props: ['modelValue'], emits: ['focus', 'update:modelValue'], template: '<div><slot/></div>' },
|
||||
ElOption: { name: 'ElOption', props: ['value', 'label', 'disabled'], template: '<span>{{ label }}</span>' },
|
||||
ElButton: { template: '<button><slot/></button>' },
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers(); vi.clearAllMocks()
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicle], page: 1, size: 30, total: 1, pages: 1 })
|
||||
vi.mocked(listDrivers).mockResolvedValue({ records: [driver], page: 1, size: 30, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('disables resources held by another trip and shows the occupying trip before saving', async () => {
|
||||
vi.mocked(getTripResourceOccupancy).mockImplementation(async (_, kind, ids) => ids.map(resourceId => ({ resourceId, occupiedTripNo: kind === 'VEHICLE' ? 'TR-OTHER' : 'TR-CURRENT', available: kind === 'DRIVER' })))
|
||||
const wrapper = mount()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
selects.forEach(select => select.vm.$emit('focus')); await vi.advanceTimersByTimeAsync(250); await flushPromises()
|
||||
const options = wrapper.findAllComponents({ name: 'ElOption' })
|
||||
expect(options[0]!.props()).toMatchObject({ value: vehicle.id, disabled: true })
|
||||
expect(options[0]!.props('label')).toContain('被车次 TR-OTHER 占用')
|
||||
expect(options[1]!.props('disabled')).toBe(false)
|
||||
expect(options[1]!.props('label')).toContain('当前车次')
|
||||
expect(getTripResourceOccupancy).toHaveBeenCalledTimes(2)
|
||||
selects[0]!.vm.$emit('update:modelValue', vehicle.id); selects[1]!.vm.$emit('update:modelValue', driver.id); await flushPromises()
|
||||
await wrapper.find('button').trigger('click'); expect(assignTrip).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not expose directory candidates as free when occupancy lookup fails', async () => {
|
||||
vi.mocked(getTripResourceOccupancy).mockRejectedValue(new Error('查询占用失败'))
|
||||
const wrapper = mount(); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('focus')
|
||||
await vi.advanceTimersByTimeAsync(250); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { assignTrip } from '../api/trips'
|
||||
import { assignTrip, getTripResourceOccupancy, type TripResourceOccupancy } from '../api/trips'
|
||||
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
@@ -14,41 +14,64 @@ import type { TripDetail } from '../tripTypes'
|
||||
const props = defineProps<{ detail: TripDetail }>()
|
||||
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||||
const vehicleOccupancy = ref<Record<string, TripResourceOccupancy>>({}), driverOccupancy = ref<Record<string, TripResourceOccupancy>>({})
|
||||
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
|
||||
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let vehicleRequest = 0, driverRequest = 0, generation = 0
|
||||
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
|
||||
/** 本车占用可保留,其他车次占用显示具体业务号并禁止选择。 */
|
||||
function occupancyLabel(row: TripResourceOccupancy | undefined): string {
|
||||
if (!row) return ' · 占用待查询'
|
||||
if (!row.available) return ` · 被车次 ${row.occupiedTripNo} 占用`
|
||||
return row.occupiedTripNo ? ' · 当前车次' : ' · 空闲'
|
||||
}
|
||||
watch(() => props.detail.trip, trip => {
|
||||
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
|
||||
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
|
||||
vehicleOccupancy.value = {}; driverOccupancy.value = {}
|
||||
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
|
||||
}, { immediate: true })
|
||||
/** 下拉搜索先批量核对占用,失败不把未知状态的资源当成空闲。 */
|
||||
function searchVehicles(keyword = '') {
|
||||
clearTimeout(vehicleTimer); const request = ++vehicleRequest
|
||||
vehicles.value = []; vehicleOccupancy.value = {}; vehicleLoading.value = true
|
||||
vehicleTimer = setTimeout(async () => {
|
||||
vehicleLoading.value = true
|
||||
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (request === vehicleRequest) vehicles.value = result.records }
|
||||
try {
|
||||
const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' })
|
||||
if (request !== vehicleRequest) return
|
||||
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'VEHICLE', result.records.map(row => row.id))
|
||||
if (request === vehicleRequest) { vehicleOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); vehicles.value = result.records }
|
||||
}
|
||||
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
|
||||
finally { if (request === vehicleRequest) vehicleLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
/** 证照合格与资源空闲分别展示,不能用可派车资格代替未占用。 */
|
||||
function searchDrivers(keyword = '') {
|
||||
clearTimeout(driverTimer); const request = ++driverRequest
|
||||
drivers.value = []; driverOccupancy.value = {}; driverLoading.value = true
|
||||
driverTimer = setTimeout(async () => {
|
||||
driverLoading.value = true
|
||||
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt }); if (request === driverRequest) drivers.value = result.records }
|
||||
try {
|
||||
const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt })
|
||||
if (request !== driverRequest) return
|
||||
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'DRIVER', result.records.map(row => row.id))
|
||||
if (request === driverRequest) { driverOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); drivers.value = result.records }
|
||||
}
|
||||
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
|
||||
finally { if (request === driverRequest) driverLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
/** 候选阶段挡住已知占用,保存仍由后端锁定复验以防同时被其他人分配。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
|
||||
if (vehicleLoading.value || driverLoading.value) { ElMessage.warning('请等待车辆和司机占用查询完成'); return }
|
||||
if (vehicleOccupancy.value[vehicleId.value]?.available === false || driverOccupancy.value[driverId.value]?.available === false) { ElMessage.warning('所选车辆或司机已被其他车次占用,请重新选择'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
|
||||
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
|
||||
if (current === generation) { emit('saved', result); ElMessage.success('车辆和司机已分配,请重新核对装载') }
|
||||
if (current === generation) { emit('saved', result); ElMessage.success(props.detail.trip.tripPurpose === 'PICKUP' ? '车辆和司机已分配,可安排取货发车' : '车辆和司机已分配,请重新核对装载') }
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
@@ -60,11 +83,11 @@ onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTi
|
||||
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
|
||||
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
|
||||
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位`" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :disabled="vehicleOccupancy[vehicle.id]?.available !== true" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位${occupancyLabel(vehicleOccupancy[vehicle.id])}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
|
||||
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}`" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :disabled="driverOccupancy[driver.id]?.available !== true || !driver.qualification.canDispatch" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}${occupancyLabel(driverOccupancy[driver.id])}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
|
||||
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import TripPickupPanel from './TripPickupPanel.vue'
|
||||
import { commandPickupTask, commandPickupTrip, getPickupTasks, type PickupTask } from '../api/pickups'
|
||||
import { getTrip } from '../api/trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
|
||||
vi.mock('../api/trips', () => ({ getTrip: vi.fn() }))
|
||||
vi.mock('../api/pickups', () => ({ getPickupTasks: vi.fn(), commandPickupTask: vi.fn(), commandPickupTrip: vi.fn(), uploadPickupEvidence: vi.fn(), downloadPickupEvidence: vi.fn(), pickupStatusLabels: {} }))
|
||||
const row: PickupTask = { customerId: 'C1', transferNo: 'WT-1', status: 'PICKED', evidence: [], lastlegOrderNo: 'LL-1', warehouseId: 'W1', pickupAddress: 'TEST Pickup Ave', contactName: '验收联系人', contactPhone: '0000000000', packageCount: 3, tripNo: 'TR-1', vehiclePlate: 'TEST-V1', driverName: '验收司机', driverPhone: '0000000000', plannedDepartureAt: null, actualPackages: 3, pickedAt: null, arrivedAt: null, remark: null }
|
||||
const detail = (status: string) => ({ trip: { tripNo: 'TR-1', status, recordVersion: 2, vehicleId: 'V1', driverId: 'D1' } }) as TripDetail
|
||||
function mount(status: string) {
|
||||
return shallowMount(TripPickupPanel, { props: { detail: detail(status) }, global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot v-for="row in data" name="actions" :row="row"/></div>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
LoadErrorState: { name: 'LoadErrorState', emits: ['retry'], template: '<button @click="$emit(\'retry\')">重试读取</button>' },
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getPickupTasks).mockResolvedValue([row]) })
|
||||
|
||||
it('locks parent actions and retries the exact departure command after an unknown result', async () => {
|
||||
vi.mocked(commandPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
|
||||
const wrapper = mount('PLANNED'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认取货发车')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('locked')?.at(-1)).toEqual([true])
|
||||
const body = vi.mocked(commandPickupTrip).mock.calls[0]![2]
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试原操作')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandPickupTrip).mock.calls[1]![2]).toBe(body)
|
||||
expect(wrapper.emitted('locked')?.at(-1)).toEqual([false])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('retries only reading after arrival committed but the trip refresh failed', async () => {
|
||||
vi.mocked(commandPickupTask).mockResolvedValue([{ ...row, status: 'ARRIVED' }])
|
||||
vi.mocked(getTrip).mockRejectedValueOnce(new Error('refresh lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
|
||||
const wrapper = mount('IN_TRANSIT'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认送仓')!.trigger('click'); await flushPromises()
|
||||
expect(commandPickupTask).toHaveBeenCalledTimes(1)
|
||||
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||
expect(commandPickupTask).toHaveBeenCalledTimes(1)
|
||||
expect(getTrip).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTrip } from '../api/trips'
|
||||
import { getPickupTasks, commandPickupTrip, commandPickupTask, uploadPickupEvidence, downloadPickupEvidence, pickupStatusLabels,
|
||||
type PickupTask, type PickupCommand, type PickupConfirmation, type PickupEvidence } from '../api/pickups'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
const props = defineProps<{ detail: TripDetail }>(), emit = defineEmits<{ saved: [detail: TripDetail]; locked: [value: boolean] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<PickupTask[]>([]), loading = ref(false), busy = ref(false), error = ref('')
|
||||
const selected = ref<PickupTask | null>(null), open = ref(false), actual = ref(1), remark = ref(''), evidence = ref<PickupEvidence[]>([])
|
||||
const needsTripRefresh = ref(false)
|
||||
const pending = ref<{ action: 'depart' | 'complete' | 'picked' | 'arrive'; row: PickupTask | null; body: PickupCommand | PickupConfirmation } | null>(null)
|
||||
// 命令结果未知时阻止父页面刷新和改派,保留原请求用于幂等重试。
|
||||
watch(() => busy.value || !!pending.value, value => emit('locked', value), { flush: 'sync' })
|
||||
let sequence = 0
|
||||
const canComplete = computed(() => rows.value.length > 0 && rows.value.every(row => row.status === 'ARRIVED'))
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 175, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
|
||||
{ key: 'status', label: '取货进度', slotName: 'status', width: 110 }, { key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 270 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 145 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'actualPackages', label: '实提箱数', slotName: 'actual', width: 100 },
|
||||
{ key: 'evidence', label: '取货凭证', slotName: 'evidence', minWidth: 180 }, { key: 'remark', prop: 'remark', label: '取货备注', minWidth: 180 },
|
||||
]
|
||||
/** 车次变化时丢弃旧请求;同车版本变化保留未确定结果的命令。 */
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = ''
|
||||
try {
|
||||
if (needsTripRefresh.value) {
|
||||
const detail = await getTrip(props.detail.trip.tripNo)
|
||||
if (current !== sequence) return
|
||||
emit('saved', detail); needsTripRefresh.value = false
|
||||
}
|
||||
const value = await getPickupTasks(props.detail.trip.tripNo); if (current === sequence) rows.value = value
|
||||
}
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货任务加载失败').message }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
/** 逐票录入实提,默认箱数仅供编辑,需操作员确认。 */
|
||||
function edit(row: PickupTask) {
|
||||
if (pending.value) return
|
||||
selected.value = row; actual.value = row.packageCount; remark.value = ''; evidence.value = []; open.value = true
|
||||
}
|
||||
/** 凭证上传与确认分开,重试确认不重新上传文件。 */
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]
|
||||
if (!file || !selected.value || busy.value) return
|
||||
busy.value = true; error.value = ''; const current = sequence
|
||||
try { const value = await uploadPickupEvidence(props.detail.trip.tripNo, selected.value, file); if (current === sequence) evidence.value.push(value) }
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货凭证上传失败').message }
|
||||
finally { if (current === sequence) busy.value = false; input.value = '' }
|
||||
}
|
||||
/** 失败结果未知时固定原请求,成功后只刷新车次版本和成员。 */
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value) return
|
||||
const request = pending.value, current = sequence, number = props.detail.trip.tripNo
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
if (request.action === 'depart' || request.action === 'complete') {
|
||||
const detail = await commandPickupTrip(number, request.action, request.body as PickupCommand)
|
||||
if (current === sequence) { emit('saved', detail); pending.value = null }
|
||||
} else if (request.row) {
|
||||
const tasks = await commandPickupTask(number, request.row, request.action, request.body)
|
||||
if (current !== sequence) return
|
||||
// 业务命令已经成功,后续刷新失败只重试读取,不能再次生成执行命令。
|
||||
rows.value = tasks; pending.value = null; open.value = false; needsTripRefresh.value = true
|
||||
const detail = await getTrip(number); if (current === sequence) { emit('saved', detail); needsTripRefresh.value = false }
|
||||
}
|
||||
} catch (cause) {
|
||||
if (current === sequence) { error.value = getApiErrorDetails(cause, '取货操作未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
|
||||
} finally { if (current === sequence) busy.value = false }
|
||||
}
|
||||
/** 发车、送仓和结案提交前说明本次事实。 */
|
||||
async function command(action: 'depart' | 'arrive' | 'complete', row: PickupTask | null = null) {
|
||||
if (pending.value || busy.value || needsTripRefresh.value) return
|
||||
const messages = { depart: '确认车辆和司机已出发执行本车取货任务?', arrive: '确认该单货物已送达选定海外仓?确认后仓库可开始 PDA 清点。', complete: '确认本车全部取货订单已送仓,结束本次取货运输?' }
|
||||
try { await ElMessageBox.confirm(messages[action], '确认取货运输', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
pending.value = { action, row, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), reason: messages[action] } }
|
||||
await execute()
|
||||
}
|
||||
/** 照片和实提箱数为取货确认的必要信息。 */
|
||||
async function confirmPickup() {
|
||||
if (pending.value) { await execute(); return }
|
||||
if (needsTripRefresh.value) { ElMessage.warning('请先刷新车次后继续'); return }
|
||||
if (!selected.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length) { ElMessage.warning('请填写实提箱数并上传至少一份取货凭证'); return }
|
||||
pending.value = { action: 'picked', row: selected.value, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: remark.value.trim() } }
|
||||
await execute()
|
||||
}
|
||||
/** 只下载本票确认的文件,使用完即释放临时地址。 */
|
||||
async function download(row: PickupTask, file: PickupEvidence) {
|
||||
try { const blob = await downloadPickupEvidence(row, file.fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败').message }
|
||||
}
|
||||
/** 原命令结果未知时保持弹框与请求快照。 */
|
||||
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先确认原取货操作的结果'); return }; done() }
|
||||
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先重试并确认原取货操作'); return false }; return true })
|
||||
watch(() => props.detail.trip.tripNo, () => { pending.value = null; open.value = false; rows.value = []; void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="本车仓储转运取货任务">
|
||||
<div class="density-actions pickup-actions">
|
||||
<el-button v-if="detail.trip.status === 'PLANNED' && access.can(permission.tripAssign)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !detail.trip.vehicleId || !detail.trip.driverId" @click="command('depart')">确认取货发车</el-button>
|
||||
<el-button v-if="detail.trip.status === 'IN_TRANSIT' && access.can(permission.tripComplete)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !canComplete" @click="command('complete')">取货运输结案</el-button>
|
||||
<el-button :disabled="busy || !!pending" @click="load">刷新取货任务</el-button>
|
||||
<el-button v-if="pending && !open" type="primary" :loading="busy" @click="execute">重试原操作</el-button>
|
||||
</div>
|
||||
<LoadErrorState v-if="error && !open" title="取货操作未完成" :message="error" @retry="pending ? execute() : load()" />
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="transferNo" empty-text="暂无取货成员">
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'ASSIGNED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="edit(row as PickupTask)">确认取货</el-button><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'PICKED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="command('arrive', row as PickupTask)">确认送仓</el-button></div></template>
|
||||
<template #status="{ row }">{{ pickupStatusLabels[row.status] || row.status }}</template><template #actual="{ row }">{{ row.actualPackages ?? '待取货' }}</template>
|
||||
<template #evidence="{ row }"><div v-for="file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)">{{ file.fileName }}</el-button></div></template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
|
||||
<p>{{ selected?.transferNo }} · {{ selected?.pickupAddress }}</p>
|
||||
<el-form label-position="top" :disabled="busy || !!pending">
|
||||
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" placeholder="请输入实提箱数" /></el-form-item>
|
||||
<el-form-item label="取货凭证" required><div><input type="file" accept="image/png,image/jpeg,application/pdf" :disabled="busy || !!pending || evidence.length >= 10" @change="upload"><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></div></el-form-item>
|
||||
<el-form-item label="取货备注"><el-input v-model="remark" type="textarea" maxlength="1000" placeholder="请输入取货备注(选填)" /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.pickup-actions{margin-bottom:16px}</style>
|
||||
@@ -89,7 +89,7 @@ watch([page, size], loadInquiries)
|
||||
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车接货</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
|
||||
@@ -85,7 +85,7 @@ async function load() {
|
||||
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'TODO' ? 'TODO' : manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
|
||||
const value = await listCapacityInquiries(query); if (current === sequence) { inquiries.value = value.records; total.value = value.total }
|
||||
}
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车接货读取失败') }
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '散板需求读取失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
|
||||
@@ -120,12 +120,12 @@ async function recheckEditor() {
|
||||
}
|
||||
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车接货">
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="散板需求">
|
||||
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||
<template #toolbar-start><h1>拼车接货</h1></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||
<template #toolbar-start><h1>散板需求</h1></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车接货页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="散板需求页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -142,7 +142,7 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
|
||||
<template #toolbar-start><h1>{{ applied.scope === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}</h1></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车接货页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="散板需求页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import TripPickupPanel from '../components/TripPickupPanel.vue'
|
||||
import TripAssignmentPanel from '../components/TripAssignmentPanel.vue'
|
||||
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
|
||||
import TripBookingPanel from '../components/TripBookingPanel.vue'
|
||||
@@ -32,6 +33,7 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
|
||||
const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('')
|
||||
const offerSettingsOpen = ref(false)
|
||||
const pickupLocked = ref(false)
|
||||
const appointmentsOpen = ref(false)
|
||||
const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null)
|
||||
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
|
||||
@@ -40,6 +42,7 @@ const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new
|
||||
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
|
||||
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
|
||||
let generation = 0, inspectionSequence = 0, historySequence = 0
|
||||
const isPickup = computed(() => detail.value?.trip.tripPurpose === 'PICKUP')
|
||||
const planned = computed(() => detail.value?.trip.status === 'PLANNED')
|
||||
const editable = computed(() => planned.value && access.can(permission.tripEdit))
|
||||
const time = (value: string | null | undefined) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
@@ -58,7 +61,7 @@ const outcomeLabels: Record<string, string> = { DELIVERED: '已签收', RETURNED
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作时间', slotName: 'time', minWidth: 175 },
|
||||
{ key: 'action', label: '操作', slotName: 'action', minWidth: 145 }, { key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
|
||||
{ key: 'vehicle', prop: 'snapshot.trip.vehiclePlate', label: '当时车辆', minWidth: 145 }, { key: 'driver', prop: 'snapshot.trip.driverName', label: '当时司机', minWidth: 125 }]
|
||||
const actionLabels: Record<string, string> = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
|
||||
const actionLabels: Record<string, string> = { PICKUP_PLAN: '安排上门取货', PICKUP_DEPART: '取货发车', PICKUP_PICKED: '确认取货', PICKUP_ARRIVED: '确认送仓', PICKUP_COMPLETE: '取货运输结案', CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
|
||||
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
|
||||
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
|
||||
function accept(value: TripDetail) {
|
||||
@@ -67,6 +70,7 @@ function accept(value: TripDetail) {
|
||||
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
|
||||
}
|
||||
async function load() {
|
||||
if (pickupLocked.value) { ElMessage.warning('请先重试并确认原取货操作'); return }
|
||||
if (appointmentsPanel.value && !await appointmentsPanel.value.canLeave()) return
|
||||
const current = ++generation; inspectionSequence++; historySequence++
|
||||
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
|
||||
@@ -78,7 +82,7 @@ async function load() {
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function run(action: string, command: () => Promise<TripDetail>): Promise<boolean> {
|
||||
if (busy.value) return false
|
||||
if (busy.value || pickupLocked.value) return false
|
||||
const current = generation; busy.value = action; operationError.value = null
|
||||
try { const result = await command(); if (current !== generation) return false; accept(result); return true }
|
||||
catch (cause) {
|
||||
@@ -185,28 +189,29 @@ async function loadHistory() {
|
||||
}
|
||||
watch(() => route.params.tripNo, load, { immediate: true })
|
||||
watch(() => route.query.tab, tab => { activeTab.value = tab === 'loading' ? 'loading' : 'overview' })
|
||||
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
|
||||
watch(activeTab, tab => { if (tab === 'completion' && !isPickup.value) void inspect(); if (tab === 'history') void loadHistory() })
|
||||
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
|
||||
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy || pickupLocked" @click="load">刷新车次</el-button><el-button v-if="!isPickup && access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy || pickupLocked" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || pickupLocked || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
|
||||
<div v-loading="loading"><template v-if="detail">
|
||||
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [['起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
|
||||
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [[isPickup ? '入仓仓库' : '起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
|
||||
<p>{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}</p><p v-if="detail.driverQualification">{{ driverQualificationLabels[detail.driverQualification.reason] }}<span v-if="detail.driverQualification.expiringSoon"> · 证照将在 30 天内到期,请留意任务延期</span></p>
|
||||
</FormSection>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<template v-if="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<div v-if="activeTab === 'overview' || isPickup" v-show="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel v-if="!pickupLocked" :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<TripPickupPanel v-if="isPickup" :detail="detail" @saved="accept" @locked="pickupLocked = $event" />
|
||||
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
||||
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
</template>
|
||||
<FormSection v-show="activeTab === 'overview'" v-if="access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
|
||||
</div>
|
||||
<FormSection v-show="activeTab === 'overview'" v-if="!isPickup && access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
|
||||
<el-button v-if="!appointmentsOpen" @click="appointmentsOpen = true">核对客户预约与后送去向</el-button>
|
||||
<TripAppointmentPanel v-if="appointmentsOpen" ref="appointmentsPanel" :key="detail.trip.tripNo" :dispatches="detail.dispatches" :previous-dispatches="detail.previousDispatches" :history-unavailable-reason="detail.appointmentHistoryUnavailableReason" :can-write="planned && !busy" @updated="refreshLinked" @work="key => router.push({ path: '/appointment-calendar', query: { workItem: key } })" @external="path => router.push(path)" />
|
||||
</FormSection>
|
||||
@@ -216,7 +221,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
<FormSection v-if="activeTab === 'capacity'" class="density-form-section" title="核对本车计划载荷"><TripLoadingPanel :key="`${detail.trip.tripNo}-capacity`" :trip="detail.trip" :editable="editable" planning @planned="capacityPlanned" /></FormSection>
|
||||
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
|
||||
<template v-if="activeTab === 'completion'">
|
||||
<FormSection v-if="detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
|
||||
<FormSection v-if="!isPickup && detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
|
||||
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
|
||||
|
||||
@@ -10,6 +10,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import PickupDemandPanel from '../components/PickupDemandPanel.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listTrips, type TripQuery } from '../api/trips'
|
||||
@@ -17,6 +18,8 @@ import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { tripStatusLabels, type FleetTrip, type TripDetail } from '../tripTypes'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const workTab = ref('trips'), pickupVisited = ref(false)
|
||||
watch(workTab, value => { if (value === 'pickups') pickupVisited.value = true })
|
||||
const rows = ref<FleetTrip[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), editorOpen = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), filters = ref<QueryModel>({ keyword: '', status: '' })
|
||||
let applied: Omit<TripQuery, 'page' | 'size'> = {}, sequence = 0
|
||||
@@ -24,7 +27,7 @@ const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: Object.entries(tripStatusLabels).map(([value, label]) => ({ value, label })) }]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 215 },
|
||||
{ key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'purpose', label: '任务类型', slotName: 'purpose', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'vehiclePlate', label: '车牌', minWidth: 130 }, { key: 'driverName', label: '司机', minWidth: 110 },
|
||||
{ key: 'departure', label: '计划发车', slotName: 'departure', minWidth: 175 }, { key: 'end', label: '司机预计结束', slotName: 'end', minWidth: 175 },
|
||||
{ key: 'route', label: '路线', slotName: 'route', minWidth: 240 }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
@@ -46,12 +49,15 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="车次管理">
|
||||
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
|
||||
<el-tabs v-model="workTab" class="density-element-tabs"><el-tab-pane label="车次管理" name="trips" /><el-tab-pane label="待取货" name="pickups" /></el-tabs>
|
||||
<PickupDemandPanel v-if="pickupVisited" v-show="workTab === 'pickups'" />
|
||||
<DataTable v-show="workTab === 'trips'" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>车次管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" :loading="loading" @search="search" @reset="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" @click="editorOpen = true">新建车次</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" @click="view(row.tripNo)">详情</el-button></div></template>
|
||||
<template #purpose="{ row }">{{ row.tripPurpose === 'PICKUP' ? '入仓取货' : '出库派送' }}</template>
|
||||
<template #status="{ row }">{{ tripStatusLabels[row.status as keyof typeof tripStatusLabels] }}</template>
|
||||
<template #departure="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #end="{ row }">{{ time(row.expectedDriverEndAt) }}</template>
|
||||
<template #route="{ row }">{{ row.stops.map((stop: FleetTrip['stops'][number]) => stop.name).join(' → ') }}</template>
|
||||
|
||||
@@ -6,13 +6,13 @@ export const overseasMasterDataRoutes = [
|
||||
path: 'overseas-master-data/capacity-offers',
|
||||
name: 'overseas-master-data-capacity-offers',
|
||||
component: () => import('./pages/CapacityOffersView.vue'),
|
||||
meta: { title: '拼车接货', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
meta: { title: '散板需求', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/capacity-offers/:offerNo',
|
||||
name: 'overseas-master-data-capacity-offer-detail',
|
||||
component: () => import('./pages/CapacityOfferDetailView.vue'),
|
||||
meta: { title: '拼车需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
meta: { title: '散板需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/fleet-quotes',
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface TripStop { name: string; address?: string | null; destinationKe
|
||||
export type ReleasedVehicleStatus = 'IN_SERVICE' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
|
||||
export interface TripReleaseInfo { actualEndAt: string; endLocation: string; vehicleStatus: ReleasedVehicleStatus | null }
|
||||
export interface FleetTrip {
|
||||
tripPurpose?: 'DELIVERY' | 'PICKUP'
|
||||
tripNo: string
|
||||
status: TripStatus
|
||||
warehouseId: string
|
||||
|
||||
@@ -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, '重派实拣数量') })) })) }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface PickupProgress {
|
||||
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
|
||||
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null
|
||||
evidence: { fileId: string; fileName: string }[]
|
||||
}
|
||||
/** 原单详情只查询本票取货进度。 */
|
||||
export async function getTransferPickupProgress(transferNo: string, customerId: string): Promise<PickupProgress | null> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<PickupProgress | null>, ApiEnvelope<PickupProgress | null>>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/pickup-progress`, { params: { customerId } }))
|
||||
}
|
||||
/** 下载当前票确认的取货凭证。 */
|
||||
export async function downloadTransferPickupEvidence(transferNo: string, customerId: string, fileId: string): Promise<Blob> {
|
||||
return http.get<Blob, Blob>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId }, responseType: 'blob' })
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -12,12 +12,13 @@ describe('贴标附件回显', () => {
|
||||
const wrapper = mountPanel(); await flushPromises()
|
||||
const rows = wrapper.findAll('.el-table__body tbody tr')
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(wrapper.text()).toContain('箱唛.pdf'); expect(wrapper.text()).toContain('SKU.pdf')
|
||||
expect(wrapper.text()).toContain('面单.pdf'); expect(wrapper.text()).toContain('明细.pdf')
|
||||
expect(wrapper.text()).toContain('BX-1 / SKU-A')
|
||||
expect(wrapper.text()).toContain('箱唛贴标文件'); expect(wrapper.text()).toContain('SKU 贴标文件')
|
||||
expect(wrapper.text()).toContain('面单文件'); expect(wrapper.text()).toContain('明细文件')
|
||||
expect(wrapper.text()).not.toContain('BX-1 / SKU-A'); expect(wrapper.text()).not.toContain('文件名称'); expect(wrapper.text()).not.toContain('查看'); expect(wrapper.text()).not.toContain('箱唛.pdf')
|
||||
expect(wrapper.findAll('thead th')).toHaveLength(2)
|
||||
expect(getRelabelOrder).not.toHaveBeenCalled()
|
||||
})
|
||||
it('预览请求使用申请号、文件分类及明细编号,并可重试失败请求', async () => {
|
||||
it('下载请求使用申请号、文件分类及明细编号,并可重试失败请求', async () => {
|
||||
vi.mocked(downloadRelabelDocument).mockRejectedValue(new Error('文件暂不可用'))
|
||||
const wrapper = mountPanel(); await flushPromises()
|
||||
const rows = wrapper.findAll('.el-table__body tbody tr')
|
||||
@@ -32,6 +33,6 @@ describe('贴标附件回显', () => {
|
||||
const wrapper = mount(RelabelDocumentsPanel, { props: { relabelNo: 'RL-1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('资料加载失败')
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('箱唛.pdf')
|
||||
expect(wrapper.text()).toContain('箱唛贴标文件')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,6 @@ const loaded = ref<RelabelDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewOpen = ref(false)
|
||||
const previewTitle = ref('')
|
||||
let generation = 0
|
||||
const current = computed(() => props.detail || loaded.value)
|
||||
/** 各申请分别加载,失败只影响资料区,不阻断订单详情。 */
|
||||
@@ -22,28 +19,26 @@ async function load() {
|
||||
catch (cause) { if (ticket === generation) error.value = cause instanceof Error ? cause.message : '贴标资料加载失败' }
|
||||
finally { if (ticket === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [props.relabelNo, props.detail], () => { clearPreview(); void load() }, { immediate: true })
|
||||
interface DocumentRow { key: string; kind: string; fileId: string; fileName: string; scope: string; itemId?: string; context?: string }
|
||||
watch(() => [props.relabelNo, props.detail], () => { void load() }, { immediate: true })
|
||||
interface DocumentRow { key: string; kind: string; fileId: string; fileName: string; scope: string; itemId?: string }
|
||||
const documents = computed<DocumentRow[]>(() => {
|
||||
const result: DocumentRow[] = []
|
||||
const application = current.value?.application
|
||||
if (!application) return result
|
||||
// 旧总文件仅作为兼容回显,避免与已分类型的总文件重复展示。
|
||||
const add = (kind: string, id: string | null | undefined, name: string | null | undefined, scope: string, itemId?: string, context?: string) => {
|
||||
if (id) result.push({ key: scope + ':' + (itemId || '') + ':' + id, kind, fileId: id, fileName: name || '未命名文件', scope, itemId, context })
|
||||
const add = (kind: string, id: string | null | undefined, name: string | null | undefined, scope: string, itemId?: string) => {
|
||||
if (id) result.push({ key: scope + ':' + (itemId || '') + ':' + id, kind, fileId: id, fileName: name || '未命名文件', scope, itemId })
|
||||
}
|
||||
add('箱唛总文件', application.cartonTotalFileId, application.cartonTotalFileName, 'CARTON_TOTAL')
|
||||
add('SKU 标签总文件', application.skuTotalFileId, application.skuTotalFileName, 'SKU_TOTAL')
|
||||
add('箱唛贴标文件', application.cartonTotalFileId, application.cartonTotalFileName, 'CARTON_TOTAL')
|
||||
add('SKU 贴标文件', application.skuTotalFileId, application.skuTotalFileName, 'SKU_TOTAL')
|
||||
if (application.totalFileId !== application.cartonTotalFileId && application.totalFileId !== application.skuTotalFileId)
|
||||
add('贴标总文件', application.totalFileId, application.totalFileName, 'TOTAL')
|
||||
add('面单文件', application.shippingLabelFileId, application.shippingLabelFileName, 'SHIPPING')
|
||||
for (const item of current.value?.items || []) add('明细文件', item.relabelFileId, item.relabelFileName, 'ITEM', String(item.id), [item.cartonMark, item.skuCode].filter(Boolean).join(' / '))
|
||||
for (const item of current.value?.items || []) add('明细文件', item.relabelFileId, item.relabelFileName, 'ITEM', String(item.id))
|
||||
return result
|
||||
})
|
||||
/** 切换申请及关闭预览时释放文件对象。 */
|
||||
function clearPreview() { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = ''; previewOpen.value = false }
|
||||
/** 使用带身份认证的下载接口,预览和保存共享同一归属校验。 */
|
||||
async function openFile(key: string, download = false) {
|
||||
/** 使用带身份认证的下载接口,保留文件归属校验和原始文件名。 */
|
||||
async function downloadFile(key: string) {
|
||||
const row = documents.value.find(document => document.key === key)
|
||||
if (!row || busy.value) return
|
||||
busy.value = true
|
||||
@@ -51,32 +46,25 @@ async function openFile(key: string, download = false) {
|
||||
try {
|
||||
const blob = await downloadRelabelDocument(props.relabelNo, row.fileId, row.scope, row.itemId)
|
||||
if (ticket !== generation) return
|
||||
if (download) {
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement('a')
|
||||
link.href = url; link.download = row.fileName; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} else { clearPreview(); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = row.fileName; previewOpen.value = true }
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement('a')
|
||||
link.href = url; link.download = row.fileName; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (cause) { ElMessage.error(cause instanceof Error ? cause.message : '文件读取失败,请重试') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { ++generation; clearPreview() })
|
||||
onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
<template>
|
||||
<section class="relabel-documents" aria-label="客户上传的贴标文件">
|
||||
<h3>贴标文件<span v-if="relabelNo"> · {{ relabelNo }}</span></h3>
|
||||
<h3>贴标文件</h3>
|
||||
<p v-if="loading">资料加载中…</p>
|
||||
<p v-else-if="error" role="alert">{{ error }} <el-button link type="primary" @click="load">重试</el-button></p>
|
||||
<el-table v-else :data="documents" row-key="key" border empty-text="暂无上传的贴标文件">
|
||||
<el-table-column prop="kind" label="文件类型" width="150" />
|
||||
<el-table-column prop="context" label="对应箱唛 / SKU" min-width="180" />
|
||||
<el-table-column prop="fileName" label="文件名称" min-width="240" />
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openFile(String(row.key))">查看</el-button><el-button link type="primary" :disabled="busy" @click="openFile(String(row.key), true)">下载</el-button></template></el-table-column>
|
||||
<el-table-column prop="kind" label="文件类型" min-width="150" />
|
||||
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="downloadFile(String(row.key))">下载</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-dialog v-model="previewOpen" :title="previewTitle" width="80%" append-to-body @closed="clearPreview"><iframe v-if="previewUrl" :src="previewUrl" :title="previewTitle" class="relabel-document-preview" /></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.relabel-documents { margin: 16px 0; }
|
||||
.relabel-documents h3 { margin: 0 0 12px; font-size: 14px; }
|
||||
.relabel-documents h3 span { font-weight: 400; }
|
||||
.relabel-document-preview { width: 100%; height: 70vh; border: 0; }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import TransferPickupProgressPanel from './TransferPickupProgressPanel.vue'
|
||||
import { getTransferPickupProgress, type PickupProgress } from '../api/transferPickups'
|
||||
|
||||
vi.mock('../api/transferPickups', () => ({ getTransferPickupProgress: vi.fn(), downloadTransferPickupEvidence: vi.fn() }))
|
||||
const progress = (tripNo: string): PickupProgress => ({ tripNo, status: 'ARRIVED', vehiclePlate: 'TEST-1', driverName: '验收司机', driverPhone: '000', plannedDepartureAt: null, actualPackages: 5, pickedAt: null, arrivedAt: null, remark: null, evidence: [] })
|
||||
it('ignores an old customer response after switching the order identity', async () => {
|
||||
let resolve!: (value: PickupProgress) => void
|
||||
vi.mocked(getTransferPickupProgress).mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValueOnce(progress('TR-NEW'))
|
||||
const wrapper = shallowMount(TransferPickupProgressPanel, { props: { customerId: 'C1', transferNo: 'WT-OLD' }, global: { stubs: { ElButton: { template: '<button><slot/></button>' } } } })
|
||||
await wrapper.setProps({ customerId: 'C2', transferNo: 'WT-NEW' }); await flushPromises(); resolve(progress('TR-OLD')); await flushPromises()
|
||||
expect(wrapper.text()).toContain('TR-NEW'); expect(wrapper.text()).not.toContain('TR-OLD')
|
||||
expect(getTransferPickupProgress).toHaveBeenLastCalledWith('WT-NEW', 'C2'); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTransferPickupProgress, downloadTransferPickupEvidence, type PickupProgress } from '../api/transferPickups'
|
||||
|
||||
const props = defineProps<{ transferNo: string; customerId: string }>()
|
||||
const progress = ref<PickupProgress | null>(null), error = ref(''), loading = ref(false)
|
||||
const labels: Record<string, string> = { PENDING_REVIEW: '待审核', WAITING: '待安排取货', ASSIGNED: '已安排取货', PICKED: '已取货', ARRIVED: '已送仓' }
|
||||
const time = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
let sequence = 0
|
||||
/** 业务号变化后忽略旧响应,客户之间不复用取货数据。 */
|
||||
async function load() {
|
||||
const current = ++sequence; progress.value = null; error.value = ''; loading.value = true
|
||||
try { const value = await getTransferPickupProgress(props.transferNo, props.customerId); if (current === sequence) progress.value = value }
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货进度加载失败').message }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
/** 凭证读取必须经过原单鉴权。 */
|
||||
async function download(file: { fileId: string; fileName: string }) {
|
||||
try { const blob = await downloadTransferPickupEvidence(props.transferNo, props.customerId, file.fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '取货凭证下载失败').message }
|
||||
}
|
||||
watch(() => [props.customerId, props.transferNo], load, { immediate: true }); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<section v-if="loading || progress || error" v-loading="loading" class="pickup-progress" aria-label="取货安排">
|
||||
<div class="pickup-heading"><h3>取货安排</h3><el-button size="small" :disabled="loading" @click="load">刷新进度</el-button></div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<dl v-if="progress" class="pickup-fields">
|
||||
<div><dt>取货进度</dt><dd>{{ labels[progress.status] || progress.status }}</dd></div><div><dt>车次号</dt><dd>{{ progress.tripNo || '待安排' }}</dd></div>
|
||||
<div><dt>车牌</dt><dd>{{ progress.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ progress.driverName || '待分配' }}</dd></div>
|
||||
<div><dt>司机电话</dt><dd>{{ progress.driverPhone || '—' }}</dd></div><div><dt>计划取货发车</dt><dd>{{ time(progress.plannedDepartureAt) }}</dd></div>
|
||||
<div><dt>实提箱数</dt><dd>{{ progress.actualPackages ?? '待取货' }}</dd></div><div><dt>取货时间</dt><dd>{{ time(progress.pickedAt) }}</dd></div>
|
||||
<div><dt>送仓时间</dt><dd>{{ time(progress.arrivedAt) }}</dd></div><div v-if="progress.remark"><dt>取货备注</dt><dd>{{ progress.remark }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="progress?.evidence.length" class="pickup-files"><span>取货凭证</span><el-button v-for="file in progress.evidence" :key="file.fileId" link type="primary" @click="download(file)">{{ file.fileName }}</el-button></div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.pickup-progress{padding:16px;border:1px solid var(--border,#e5e7eb);border-radius:6px;margin:16px 0}.pickup-heading{display:flex;justify-content:space-between;align-items:center}.pickup-heading h3{margin:0;font-size:15px}.pickup-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 20px;margin:16px 0 0}.pickup-fields>div{display:flex;gap:10px}.pickup-fields dt{color:var(--muted,#64748b);flex-shrink:0}.pickup-fields dd{margin:0;overflow-wrap:anywhere}.pickup-files{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px}</style>
|
||||
@@ -159,7 +159,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
</DataTable></AppDialog>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="increaseOpen" title="增加预报箱数" width="520px" :show-footer="false">
|
||||
<el-form label-width="100px"><el-form-item label="新增箱数"><el-input-number v-model="additionalCount" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" /></el-form-item></el-form>
|
||||
<el-form label-width="100px"><el-form-item label="新增箱数"><el-input-number v-model="additionalCount" placeholder="请输入新增箱数" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" /></el-form-item></el-form>
|
||||
<p>保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。</p>
|
||||
<el-button :disabled="increasing" @click="increaseOpen = false">取消</el-button><el-button type="primary" :loading="increasing" @click="saveIncreaseForecast">确认增加</el-button>
|
||||
</AppDialog>
|
||||
|
||||
@@ -1,96 +1,87 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import WarehouseTransferReviewDialog from './WarehouseTransferReviewDialog.vue'
|
||||
import { getWarehouseTransfer, reviewWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
|
||||
vi.mock('../api/warehouseTransfers', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../api/warehouseTransfers')>(),
|
||||
const reviewedTransfer = { id: '1', transferNo: 'WT-1', packageCount: 2, needRelabel: false, status: 'WAIT_PDA_RECEIVING', items: [], cartons: [], pallets: [], relabelBindings: [] }
|
||||
|
||||
vi.mock('../api/warehouseTransfers', () => ({
|
||||
getWarehouseTransfer: vi.fn(),
|
||||
reviewWarehouseTransfer: vi.fn(),
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const AppDialogStub = defineComponent({
|
||||
name: 'AppDialog',
|
||||
props: { modelValue: Boolean },
|
||||
props: { modelValue: Boolean, confirmLoading: Boolean, title: String, confirmText: String, cancelText: String },
|
||||
emits: ['confirm', 'update:modelValue'],
|
||||
setup(props, { slots }) {
|
||||
return () => props.modelValue ? h('section', { class: 'dialog' }, slots.default?.()) : null
|
||||
setup(props, { slots, emit }) {
|
||||
return () => props.modelValue ? h('section', [
|
||||
h('h2', props.title), slots.default?.(),
|
||||
h('button', { class: 'back', disabled: props.confirmLoading, onClick: () => emit('update:modelValue', false) }, props.cancelText),
|
||||
h('button', { class: 'approve', disabled: props.confirmLoading, onClick: () => emit('confirm') }, props.confirmText),
|
||||
]) : null
|
||||
},
|
||||
})
|
||||
|
||||
const DataTableStub = defineComponent({
|
||||
name: 'DataTable',
|
||||
props: { data: { type: Array, default: () => [] } },
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'data-table' }, JSON.stringify(props.data))
|
||||
},
|
||||
})
|
||||
|
||||
function detail(transferNo: string) {
|
||||
return {
|
||||
id: `id-${transferNo}`,
|
||||
transferNo,
|
||||
packageCount: 2,
|
||||
needRelabel: false,
|
||||
status: 'PENDING_REVIEW',
|
||||
items: [{ id: 'item-1', skuCode: 'SKU-1', productNameZh: '测试货物', packageCount: 2 }],
|
||||
cartons: [],
|
||||
pallets: [],
|
||||
relabelBindings: [],
|
||||
}
|
||||
}
|
||||
|
||||
function mountDialog(props: { modelValue: boolean; customerId: string; customerName: string; transferNo: string }) {
|
||||
function mountDialog() {
|
||||
return mount(WarehouseTransferReviewDialog, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
AppDialog: AppDialogStub,
|
||||
DataTable: DataTableStub,
|
||||
LoadErrorState: true,
|
||||
ApiErrorMeta: true,
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { template: '<label><slot /></label>' },
|
||||
ElRadioGroup: { template: '<div><slot /></div>' },
|
||||
ElRadioButton: { template: '<button type="button"><slot /></button>' },
|
||||
ElInput: { template: '<textarea />' },
|
||||
},
|
||||
},
|
||||
props: { modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' },
|
||||
global: { stubs: { AppDialog: AppDialogStub } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('WarehouseTransferReviewDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getWarehouseTransfer).mockReset()
|
||||
vi.mocked(reviewWarehouseTransfer).mockReset()
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('shows a concise confirmation without fetching detail tables or review inputs', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).toContain('仓储转运单审核')
|
||||
expect(wrapper.text()).toContain('审核通过后,转运单将进入海外仓收货打托作业。')
|
||||
expect(wrapper.find('.back').text()).toBe('返回')
|
||||
expect(wrapper.find('.approve').text()).toBe('审核通过')
|
||||
expect(wrapper.find('textarea, input, table').exists()).toBe(false)
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads and renders review details when first mounted already open', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(detail('WT-1'))
|
||||
|
||||
const wrapper = mountDialog({ modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' })
|
||||
await flushPromises()
|
||||
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledWith('20001', 'WT-1')
|
||||
expect(wrapper.text()).toContain('WT-1')
|
||||
expect(wrapper.text()).toContain('测试货物')
|
||||
wrapper.unmount()
|
||||
it('return closes without approving or rejecting the order', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await wrapper.find('.back').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
expect(reviewWarehouseTransfer).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('reviewed')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reloads details when an open dialog switches to another transfer', async () => {
|
||||
vi.mocked(getWarehouseTransfer)
|
||||
.mockResolvedValueOnce(detail('WT-1'))
|
||||
.mockResolvedValueOnce(detail('WT-2'))
|
||||
|
||||
const wrapper = mountDialog({ modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' })
|
||||
it('approves in the selected customer scope once and refreshes only after success', async () => {
|
||||
let finish!: () => void
|
||||
vi.mocked(reviewWarehouseTransfer).mockReturnValue(new Promise(resolve => { finish = () => resolve(reviewedTransfer) }))
|
||||
const wrapper = mountDialog()
|
||||
const dialog = wrapper.findComponent(AppDialogStub)
|
||||
dialog.vm.$emit('confirm')
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ customerId: '20002', transferNo: 'WT-2' })
|
||||
expect(reviewWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('20001', 'WT-1', 'APPROVE')
|
||||
expect(dialog.props('confirmLoading')).toBe(true)
|
||||
expect(wrapper.emitted('reviewed')).toBeUndefined()
|
||||
finish()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('reviewed')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
expect(dialog.props('confirmLoading')).toBe(false)
|
||||
})
|
||||
|
||||
expect(getWarehouseTransfer).toHaveBeenLastCalledWith('20002', 'WT-2')
|
||||
expect(wrapper.text()).toContain('WT-2')
|
||||
wrapper.unmount()
|
||||
it('keeps the dialog open and allows retry after an approval failure', async () => {
|
||||
vi.mocked(reviewWarehouseTransfer).mockRejectedValueOnce(new Error('审核失败')).mockResolvedValueOnce(reviewedTransfer)
|
||||
const wrapper = mountDialog()
|
||||
await wrapper.find('.approve').trigger('click')
|
||||
await flushPromises()
|
||||
expect(ElMessage.error).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.emitted('reviewed')).toBeUndefined()
|
||||
expect(wrapper.findComponent(AppDialogStub).props('confirmLoading')).toBe(false)
|
||||
await wrapper.find('.approve').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('reviewed')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,87 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircle2, XCircle } from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getWarehouseTransfer, reviewWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
import { reviewWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; customerId: string; customerName: string; transferNo: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; reviewed: [] }>()
|
||||
const open = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
const detail = ref<WarehouseTransferDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const form = reactive<{ decision: 'APPROVE' | 'REJECT'; remark: string }>({ decision: 'APPROVE', remark: '' })
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'skuCode', label: 'SKU', minWidth: 140 }, { key: 'productNameZh', label: '品名', minWidth: 160 },
|
||||
{ key: 'packageCount', label: '箱数', width: 80, align: 'right' }, { key: 'shippingMark', label: '客户箱唛', minWidth: 160 },
|
||||
]
|
||||
const bindingColumns: DataTableColumn[] = [
|
||||
{ key: 'operationType', label: '换标类型', minWidth: 120 }, { key: 'customerCartonMark', label: '客户箱唛', minWidth: 150 },
|
||||
{ key: 'originalSku', label: '原 SKU', minWidth: 120 }, { key: 'newSku', label: '新 SKU', minWidth: 120 },
|
||||
{ key: 'quantity', label: '数量', width: 80, align: 'right' }, { key: 'fbaCartonMark', label: '新箱唛', minWidth: 150 },
|
||||
]
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'sequenceNo', label: '托序', width: 70, align: 'center' }, { key: 'palletNo', label: '托盘号', minWidth: 150 },
|
||||
{ key: 'cartonCount', label: '箱数', width: 80, align: 'right' }, { key: 'volumeCbm', label: '托盘总体积(m³)', width: 150, align: 'right' },
|
||||
{ key: 'grossWeightKg', label: '托盘总重量(kg)', width: 150, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
if (!props.customerId || !props.transferNo) return
|
||||
loading.value = true; error.value = null; detail.value = null
|
||||
try { detail.value = await getWarehouseTransfer(props.customerId, props.transferNo) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '转运单审核资料加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
if (form.decision === 'REJECT' && !form.remark.trim()) { ElMessage.warning('驳回时请填写审核原因'); return }
|
||||
if (saving.value || !open.value || !props.customerId || !props.transferNo) return
|
||||
saving.value = true
|
||||
try {
|
||||
await reviewWarehouseTransfer(props.customerId, props.transferNo, form.decision, form.remark.trim() || undefined)
|
||||
ElMessage.success(form.decision === 'APPROVE' ? '转运单审核通过,已推送 PDA 收货打托' : '转运单已驳回')
|
||||
open.value = false; emit('reviewed')
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '审核提交失败,请重试').message) }
|
||||
finally { saving.value = false }
|
||||
await reviewWarehouseTransfer(props.customerId, props.transferNo, 'APPROVE')
|
||||
ElMessage.success('转运单审核通过,已推送 PDA 收货打托')
|
||||
open.value = false
|
||||
emit('reviewed')
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorDetails(cause, '审核提交失败,请重试').message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [props.modelValue, props.customerId, props.transferNo] as const,
|
||||
([value]) => {
|
||||
if (!value) return
|
||||
form.decision = 'APPROVE'
|
||||
form.remark = ''
|
||||
void load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" v-model="open" title="审核仓储转运单" width="min(920px, calc(100vw - 28px))" confirm-text="提交审核" :confirm-loading="saving" :loading="loading" @confirm="submit">
|
||||
<LoadErrorState v-if="error" compact title="审核资料加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form v-else-if="detail" label-position="top" class="review-form density-form">
|
||||
<div class="review-heading"><strong>{{ detail.transferNo }}</strong><span>{{ customerName }} · {{ detail.packageCount }} 箱 · {{ detail.needRelabel ? '需要换标' : '无需换标' }}</span></div>
|
||||
<h3>货物明细</h3><DataTable :data="detail.items" :columns="itemColumns" :show-pagination="false" row-key="id" empty-text="暂无货物明细" />
|
||||
<template v-if="detail.needRelabel"><h3>换标绑定明细</h3><DataTable :data="detail.relabelBindings" :columns="bindingColumns" :show-pagination="false" row-key="customerCartonMark" empty-text="暂无换标绑定明细" /></template>
|
||||
<template v-if="detail.pallets?.length"><h3>PDA 打托实测</h3><DataTable :data="detail.pallets" :columns="palletColumns" :show-pagination="false" row-key="id" empty-text="暂无打托数据" /></template>
|
||||
<el-form-item label="审核结论" required><el-radio-group v-model="form.decision"><el-radio-button value="APPROVE"><CheckCircle2 :size="15" />审核通过</el-radio-button><el-radio-button value="REJECT"><XCircle :size="15" />驳回</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item :label="form.decision === 'REJECT' ? '驳回原因' : '审核备注'" :required="form.decision === 'REJECT'"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<AppDialog
|
||||
v-model="open"
|
||||
class="admin-density-dialog density-confirm-dialog"
|
||||
title="仓储转运单审核"
|
||||
width="min(420px, calc(100vw - 28px))"
|
||||
align-center
|
||||
cancel-text="返回"
|
||||
confirm-text="审核通过"
|
||||
:confirm-loading="saving"
|
||||
@confirm="submit"
|
||||
>
|
||||
<div>审核通过后,转运单将进入海外仓收货打托作业。</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-form { display: grid; gap: var(--admin-space-5); }
|
||||
.review-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); }
|
||||
.review-heading span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
h3 { margin: 2px 0 0; font-size: var(--admin-font-section); }
|
||||
.review-form :deep(.el-form-item) { margin: var(--admin-space-1) 0 0; }
|
||||
.review-form :deep(.el-radio-button__inner) { display: inline-flex; align-items: center; gap: var(--admin-space-2); }
|
||||
</style>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,7 @@ import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue'
|
||||
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
|
||||
import WarehouseTransferReceiptPanel from '../components/WarehouseTransferReceiptPanel.vue'
|
||||
import TransferPickupProgressPanel from '../components/TransferPickupProgressPanel.vue'
|
||||
|
||||
interface DetailField { label: string; value: string | number; wide?: boolean }
|
||||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||||
@@ -269,6 +270,7 @@ onMounted(load)
|
||||
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
|
||||
<el-tabs v-model="activeTab" class="detail-tabs density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="basic"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>基本信息</span></template>
|
||||
<TransferPickupProgressPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" />
|
||||
<section aria-label="商业来源与关联单据">
|
||||
<h2 class="source-heading">关联单据</h2>
|
||||
<dl class="detail-fields density-fields">
|
||||
@@ -282,11 +284,12 @@ onMounted(load)
|
||||
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
|
||||
</dl>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="access.can(permission.relabelOrderList) && (detail?.order.relabelApplications?.length || detail?.order.relabelOrderNo)" name="relabel-files" label="贴标文件">
|
||||
<template v-if="activeTab === 'relabel-files'">
|
||||
<el-tab-pane v-if="access.can(permission.relabelOrderList) && (detail?.order.relabelApplications?.length || detail?.order.relabelOrderNo)" name="relabel-files">
|
||||
<template #label><span class="density-tab-icon" aria-hidden="true">📎</span><span>贴标文件</span></template>
|
||||
<section v-if="activeTab === 'relabel-files'" aria-label="贴标文件">
|
||||
<RelabelDocumentsPanel v-for="application in detail?.order.relabelApplications || []" :key="application.relabelNo" :relabel-no="application.relabelNo" />
|
||||
<RelabelDocumentsPanel v-if="!detail?.order.relabelApplications?.length && detail?.order.relabelOrderNo" :relabel-no="detail.order.relabelOrderNo" />
|
||||
</template>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><WarehouseTransferReceiptPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER' && activeTab === 'cargo'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" @summary="receiptSummary = $event" /><DataTable v-else-if="detail?.order.orderType !== 'WAREHOUSE_TRANSFER'" :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'"></DataTable></section></el-tab-pane>
|
||||
<el-tab-pane v-if="detail?.order.orderType === 'RETURN_RELABEL'" name="return-receipt"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>退货签收与箱内清点</span></template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
.admin-density-dialog.el-dialog .el-dialog__title { font-size: var(--admin-font-title, 16px); line-height: 24px; font-weight: 600; }
|
||||
.admin-density-dialog.el-dialog .el-dialog__body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--admin-space-5, 12px) var(--admin-space-6, 16px); }
|
||||
.admin-density-dialog.el-dialog .el-dialog__footer { flex: 0 0 auto; padding: var(--admin-space-3, 8px) var(--admin-space-6, 16px); border-top: 1px solid var(--cc-border); }
|
||||
/* Compact confirmations keep one message and right-aligned actions without section dividers. */
|
||||
.admin-density-dialog.density-confirm-dialog.el-dialog:not(.is-fullscreen) { margin: auto; }
|
||||
.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__header { border-bottom: 0; }
|
||||
.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__footer { border-top: 0; padding-bottom: var(--admin-space-5, 12px); }
|
||||
.admin-density-dialog.el-dialog .el-table { font-size: var(--admin-font-body, 13px); }
|
||||
.admin-density-dialog.el-dialog .el-table__cell { height: var(--admin-row-height, 36px); padding: var(--admin-space-1, 4px) 0; }
|
||||
.admin-density-dialog.el-dialog .el-table .cell { line-height: 22px; }
|
||||
|
||||
@@ -171,31 +171,35 @@ const cssRoot = (css: string) => {
|
||||
}
|
||||
|
||||
describe('approved Admin density ownership', () => {
|
||||
it('keeps appointment dialog content scrollable with fixed controls and unclipped calendar days', () => {
|
||||
const declarations = (file: string, selector: string) => {
|
||||
it('opts transfer approval into the shared centered confirmation appearance', () => {
|
||||
const dialog = read('modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue')
|
||||
const css = read('styles/adminDensity.css')
|
||||
expect(dialog).toContain('class="admin-density-dialog density-confirm-dialog"')
|
||||
expect(dialog).toContain('align-center')
|
||||
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog:not(.is-fullscreen) { margin: auto; }')
|
||||
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__header { border-bottom: 0; }')
|
||||
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__footer { border-top: 0;')
|
||||
})
|
||||
it('keeps the simplified calendar in two columns with bounded previews and scrollable daily details', () => {
|
||||
const view = parse(read('modules/appointment-calendar/AppointmentCalendarView.vue')).descriptor
|
||||
const panel = parse(read('modules/appointment-calendar/AppointmentCalendarPanel.vue')).descriptor
|
||||
expect(view.template!.content).toContain('<AppointmentCalendarPanel')
|
||||
expect(view.scriptSetup!.content).not.toMatch(/getWorkSummary|getWorkCalendar|getWorkItems/)
|
||||
const declarations = (selector: string) => {
|
||||
const values: Record<string, string> = {}
|
||||
const css = parse(read(`modules/appointment-calendar/${file}`)).descriptor.styles.map(style => style.content).join('\n')
|
||||
cssRoot(css).walkRules(selector, rule => { rule.walkDecls(decl => { values[decl.prop] = decl.value }) })
|
||||
cssRoot(panel.styles.map(style => style.content).join('\n')).walkRules(selector, rule => {
|
||||
if (rule.parent?.type !== 'root') return
|
||||
rule.walkDecls(decl => { values[decl.prop] = decl.value })
|
||||
})
|
||||
return values
|
||||
}
|
||||
const panel = declarations('AppointmentCalendarView.vue', '.work-panel')
|
||||
expect(panel.position).toBeUndefined()
|
||||
expect(panel['min-height']).toBe('0')
|
||||
expect(declarations('AppointmentCalendarView.vue', ':global(.work-dialog.el-dialog:not(.is-fullscreen))').height).toBe('88dvh')
|
||||
expect(declarations('AppointmentCalendarView.vue', ':global(.work-dialog .el-dialog__body)').overflow).toBe('hidden')
|
||||
expect(declarations('AppointmentCalendarView.vue', '.work-layout')['grid-template-columns']).toBeUndefined()
|
||||
expect(declarations('AppointmentCalendarView.vue', '.panel-body')).toMatchObject({ 'overflow-y': 'auto', 'min-height': '0' })
|
||||
expect(declarations('AppointmentCalendarView.vue', '.panel-header').flex).toBe('none')
|
||||
expect(declarations('AppointmentCalendarView.vue', '.panel-footer').flex).toBe('none')
|
||||
expect(declarations('AppointmentCalendarView.vue', '.work-row-action .el-button')).toMatchObject({ 'line-height': '20px', 'white-space': 'normal', height: 'auto' })
|
||||
expect(declarations('AppointmentCalendarView.vue', '.work-row-action :deep(.el-button > span)')).toMatchObject({ 'line-height': 'inherit', 'white-space': 'normal', 'overflow-wrap': 'anywhere', 'min-width': '0', 'max-width': '100%' })
|
||||
expect(declarations('WorkCalendarPanel.vue', '.calendar-toolbar')['justify-content']).toBe('flex-start')
|
||||
expect(declarations('AppointmentCalendarView.vue', '.work-inbox').border).toBeUndefined()
|
||||
const day = declarations('WorkCalendarPanel.vue', '.work-day')
|
||||
expect(day.height).toBe('146px')
|
||||
expect(day['min-height']).toBe(day.height)
|
||||
expect(day['max-height']).toBe(day.height)
|
||||
expect(day.overflow).not.toBe('hidden')
|
||||
expect(declarations('.appointment-calendar')['grid-template-columns']).toBe('minmax(0, 1fr) 360px')
|
||||
expect(declarations('.calendar-day').height).toBe('146px')
|
||||
expect(declarations('.week-grid .calendar-day').height).toBe('270px')
|
||||
expect(declarations('.detail-events')['overflow-y']).toBe('auto')
|
||||
expect(declarations('.detail-event dd')['overflow-wrap']).toBe('anywhere')
|
||||
expect(panel.template!.content).toContain('.slice(0, 3)')
|
||||
expect(panel.template!.content).not.toContain('新增预约')
|
||||
})
|
||||
it('shares list status and category tab styles across registered list consumers', () => {
|
||||
for (const file of [
|
||||
@@ -363,7 +367,7 @@ describe('approved Admin density ownership', () => {
|
||||
visit(node.children)
|
||||
} }
|
||||
visit(ast.children)
|
||||
expect(panes).toBe(file === 'LastMileOrderDetailView' ? 6 : 4)
|
||||
expect(panes).toBe(file === 'LastMileOrderDetailView' ? 7 : 4)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -443,6 +447,10 @@ describe('approved Admin density ownership', () => {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 1) continue
|
||||
const classes = classesOf(node)
|
||||
if (classes.includes('density-confirm-dialog')) {
|
||||
expect(classes, `${file}: confirmation density`).toContain('admin-density-dialog')
|
||||
checked += 1
|
||||
}
|
||||
if (node.tag === 'DataTable' || node.tag === 'LoadingListDetailDrawer' || classes.some(name => ['density-action', 'density-actions', 'density-tabs', 'density-fields', 'density-table', 'density-tab-icon', 'density-back'].includes(name))) {
|
||||
expect(insideDensity, `${file}: ${classes.join(' ')} lacks its density ancestor`).toBe(true)
|
||||
checked += 1
|
||||
|
||||
Reference in New Issue
Block a user