Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test
This commit is contained in:
@@ -10,11 +10,26 @@ describe('appointment calendar interactions', () => {
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
|
||||
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: [] },
|
||||
] })
|
||||
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 条参考计划')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
it('selects an already loaded date without requesting data again', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无到港或送仓预约')
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
|
||||
})
|
||||
|
||||
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
|
||||
@@ -44,6 +59,7 @@ describe('appointment calendar interactions', () => {
|
||||
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 项安排"]')
|
||||
|
||||
@@ -27,18 +27,22 @@ const props = withDefaults(defineProps<{
|
||||
initialDate?: string
|
||||
}>(), { 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]
|
||||
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 dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||
const grouped = computed(() => {
|
||||
const result = new Map<string, CalendarEvent[]>()
|
||||
for (const event of props.events) {
|
||||
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) || []
|
||||
@@ -50,6 +54,7 @@ const grouped = computed(() => {
|
||||
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'
|
||||
@@ -76,7 +81,6 @@ 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>
|
||||
<span class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span class="legend trip">用车计划</span>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
|
||||
@@ -90,14 +94,18 @@ defineExpose({ selectedDate, mode })
|
||||
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||
</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>
|
||||
<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)">
|
||||
: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 ? '(部分来源未加载)' : ''}`">
|
||||
<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>
|
||||
@@ -106,13 +114,13 @@ defineExpose({ selectedDate, mode })
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail" aria-label="当天安排">
|
||||
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分来源未加载)</span></p></header>
|
||||
<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>
|
||||
<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 ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</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>
|
||||
<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"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<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.canEdit || event.canDelete || event.sourcePath" class="density-actions">
|
||||
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
|
||||
@@ -126,12 +134,16 @@ defineExpose({ selectedDate, mode })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 2.5fr) minmax(320px, 1fr); gap: 16px; color: #344054; }
|
||||
.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; }
|
||||
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; padding: 16px; border-bottom: 1px solid #edf0f6; }
|
||||
.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-label > svg { color: #617ce9; }
|
||||
.calendar-label > strong { font-size: 16px; }
|
||||
.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; }
|
||||
@@ -143,16 +155,16 @@ defineExpose({ selectedDate, mode })
|
||||
.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: 12px 14px; font-size: 13px; }
|
||||
.calendar-grid { gap: 7px; padding: 12px; background: #fbfcff; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 128px; min-width: 0; padding: 8px; border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
|
||||
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); 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: 3px; margin-top: 6px; }
|
||||
.event-chip { display: block; padding: 3px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.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; }
|
||||
@@ -163,17 +175,18 @@ defineExpose({ selectedDate, mode })
|
||||
.event-more, .empty-day { color: #667085; font-size: 12px; }
|
||||
.week-grid .calendar-day { height: 220px; }
|
||||
.calendar-detail { align-self: start; }
|
||||
.calendar-detail > header { padding: 18px; border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-detail > header > strong { font-size: 17px; }
|
||||
.calendar-detail header p { margin: 10px 0 0; font-size: 13px; color: #8491a5; }
|
||||
.calendar-detail header b { color: #617ce9; font-size: 17px; }
|
||||
.detail-events { padding: 14px; max-height: 650px; overflow-y: auto; }
|
||||
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: 16px; padding-top: 16px; }
|
||||
.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; }
|
||||
.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: 15px; margin: 14px 0 8px; overflow-wrap: anywhere; }
|
||||
.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: 12px; margin: 16px 0; }
|
||||
.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; }
|
||||
|
||||
@@ -27,10 +27,11 @@ const candidates = [
|
||||
{ lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
|
||||
{ lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
|
||||
]
|
||||
function render() {
|
||||
return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
|
||||
function render(openCalendar = true) {
|
||||
const wrapper = mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'incomplete'], template: '<button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button>' },
|
||||
AppointmentContainerPanel: true,
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete'], template: '<button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /><button data-save @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { template: '<label><slot /></label>' },
|
||||
@@ -42,6 +43,8 @@ function render() {
|
||||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
|
||||
LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '<div>合批工作区</div>' },
|
||||
} } })
|
||||
if (openCalendar) void wrapper.findAll('button').find(button => button.text() === '预约日历')!.trigger('click')
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('appointment calendar data integration', () => {
|
||||
@@ -58,6 +61,25 @@ describe('appointment calendar data integration', () => {
|
||||
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
|
||||
})
|
||||
|
||||
it('keeps exactly the documented three work views and defaults to the container list without calendar requests', async () => {
|
||||
const wrapper = render(false); await flushPromises()
|
||||
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
|
||||
expect(tabs.map(button => button.text())).toEqual(['提柜与到仓', '送仓与排车', '预约日历'])
|
||||
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.text()).not.toContain('美国预约总览')
|
||||
expect(listFbaAppointmentCalendar).not.toHaveBeenCalled()
|
||||
expect(listArrivalCalendar).not.toHaveBeenCalled()
|
||||
await tabs[2]!.trigger('click'); await flushPromises()
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||||
await tabs[0]!.trigger('click'); await tabs[2]!.trigger('click'); await flushPromises()
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('distinguishes missing country data from truncation instead of suggesting a narrower date range', async () => {
|
||||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [], truncated: false, unclassifiedCount: 74, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('74 条提柜记录')
|
||||
expect(wrapper.text()).not.toContain('缩小日期范围')
|
||||
})
|
||||
it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => {
|
||||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',bookingNo: 'CB-TEST',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
|
||||
const wrapper=render(); await flushPromises()
|
||||
@@ -119,6 +141,7 @@ describe('appointment calendar data integration', () => {
|
||||
})
|
||||
it('only searches active FBA warehouses and loads cargo automatically after selection', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
|
||||
@@ -153,6 +176,7 @@ describe('appointment calendar data integration', () => {
|
||||
|
||||
it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
@@ -170,6 +194,7 @@ describe('appointment calendar data integration', () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof listInboundOrders>>) => void
|
||||
vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
@@ -183,6 +208,7 @@ describe('appointment calendar data integration', () => {
|
||||
|
||||
it('searches by keyword and exposes warehouse failures without accepting free text', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))!
|
||||
|
||||
@@ -35,9 +35,14 @@ const selectedSource = ref<PlanningSource | null>(null)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkLoading = ref(false)
|
||||
let linkRequest = 0
|
||||
const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar')
|
||||
const workView = ref<'calendar' | 'cargo' | 'containers'>('containers')
|
||||
const calendarVisited = ref(false)
|
||||
const showTripLayer = ref(false)
|
||||
const cargoVisited = ref(false)
|
||||
watch(workView, value => { if (value === 'cargo') cargoVisited.value = true })
|
||||
watch(workView, value => {
|
||||
if (value === 'cargo') cargoVisited.value = true
|
||||
if (value === 'calendar' && !calendarVisited.value) { calendarVisited.value = true; void load(); void loadUnscheduled() }
|
||||
})
|
||||
const appointments = ref<FbaAppointmentDto[]>([])
|
||||
const arrivals = ref<ArrivalCalendarEntry[]>([])
|
||||
const pickups = ref<PickupCalendarEntry[]>([])
|
||||
@@ -180,7 +185,7 @@ async function load(nextRange = range.value) {
|
||||
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
|
||||
if (pickupResult.status === 'fulfilled' && pickupResult.value) {
|
||||
pickups.value = pickupResult.value.records
|
||||
if (pickupResult.value.truncated || Number(pickupResult.value.unclassifiedCount) > 0) pickupNotice.value = '提柜来源有超出数量上限或目的仓国家待核实的记录,当前统计不完整,请缩小日期范围或完善仓库资料。'
|
||||
pickupNotice.value = [pickupResult.value.truncated ? '提柜安排超出读取上限,请缩小日期范围。' : '', Number(pickupResult.value.unclassifiedCount) > 0 ? `有 ${Number(pickupResult.value.unclassifiedCount)} 条提柜记录的目的仓国家待核实,请完善仓库资料后重新核对;这些记录未计入当前范围。` : ''].filter(Boolean).join(' ')
|
||||
} else if (pickupResult.status === 'rejected') sourceErrors.value.push({ title: '提柜预约加载失败', details: getApiErrorDetails(pickupResult.reason, '提柜预约加载失败') })
|
||||
if (tripResult.status === 'fulfilled' && tripResult.value) {
|
||||
const projection = projectTripCalendar(tripResult.value, nextRange)
|
||||
@@ -364,30 +369,32 @@ function arrangeLinkedSource() {
|
||||
}
|
||||
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
|
||||
|
||||
onMounted(() => { void load(); void loadUnscheduled() })
|
||||
onMounted(() => { if (!access.can(permission.lastMileOrderList)) workView.value = 'calendar' })
|
||||
onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density">
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<p v-if="linkLoading">正在核对预约货源…</p>
|
||||
<LoadErrorState v-if="linkError" title="预约货源核对失败" :message="linkError.message" :loading="linkLoading" @retry="resolveLink" />
|
||||
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(permission.inboundOrderAppointment)" @arrange="arrangeLinkedSource" @close="linkedSource = null" />
|
||||
<div class="appointment-heading">
|
||||
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="workView = 'containers'">提柜与到仓</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">送仓与排车</button>
|
||||
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="workView = 'calendar'">预约日历</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="workView = 'containers'">整柜提柜与接仓</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">货物与预约合批</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cargoVisited" v-show="workView === 'cargo'"><AppointmentCargoPanel :external-source="selectedSource" @source-accepted="selectedSource = null" @created="load(); loadUnscheduled()" /></div>
|
||||
<AppointmentContainerPanel v-if="workView === 'containers'" />
|
||||
<div v-show="workView === 'calendar'">
|
||||
<div v-if="calendarVisited" v-show="workView === 'calendar'">
|
||||
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
|
||||
<details v-if="unscheduledTotal" class="appointment-extra"><summary>未定时安排({{ unscheduledTotal }})</summary><DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" row-key="appointmentNo" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()"><template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template></DataTable></details>
|
||||
<p v-if="pickupNotice" role="status">{{ pickupNotice }}</p>
|
||||
<p v-if="tripNotice" role="status">{{ tripNotice }}</p>
|
||||
<details v-if="unresolvedTrips.length" class="appointment-extra">
|
||||
<details v-if="pickupNotice" class="calendar-notice"><summary>部分提柜记录待核实</summary><p>{{ pickupNotice }}</p></details>
|
||||
<details v-if="showTripLayer && tripNotice" class="calendar-notice"><summary>部分用车计划待核实</summary><p>{{ tripNotice }}</p></details>
|
||||
<details v-if="showTripLayer && unresolvedTrips.length" class="appointment-extra">
|
||||
<summary>待核实地点的车次({{ unresolvedTrips.length }})</summary>
|
||||
<p>检索窗口内的车次,尚无法确定当地日期。原计划时间保留 UTC,不用浏览器时区换算。</p>
|
||||
<DataTable v-model:page="unresolvedPage" v-model:page-size="unresolvedSize" :data="unresolvedRows" :columns="unresolvedColumns" :total="unresolvedTrips.length" row-key="tripNo" @update:page-size="unresolvedPage = 1">
|
||||
@@ -395,7 +402,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
||||
<template #issues="{ row }">{{ tripIssues(row as TripCalendarEntry) }}</template>
|
||||
</DataTable>
|
||||
</details>
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || !!tripNotice" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" @open="openSource" />
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || (showTripLayer && !!tripNotice)" :can-create="access.can(permission.inboundOrderAppointment)" @layers="showTripLayer = $event.trips" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" @open="openSource" />
|
||||
</div>
|
||||
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
|
||||
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
||||
@@ -429,6 +436,11 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-notice { margin: var(--admin-space-2) 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.calendar-notice summary { cursor: pointer; }
|
||||
.calendar-notice p { margin: var(--admin-space-2) 0; }
|
||||
.appointment-heading { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||||
.appointment-heading .density-status-tabs { flex: 1; margin: 0; }
|
||||
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
|
||||
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
|
||||
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -8,7 +8,11 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
||||
import { listImpacts, refreshImpacts, impactStatus } from './impactApi'
|
||||
|
||||
const impactReady = ref(false), impactError = ref<ApiErrorDetails | null>(null)
|
||||
const impactId = ref<string | null>(null), refreshing = ref(false), onlyChanges = ref(false)
|
||||
const access = useAccessPreviewStore()
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }]
|
||||
@@ -16,26 +20,44 @@ const rows = ref<PlanningContainer[]>([]), page = ref(1), size = ref(20), total
|
||||
const loading = ref(false), opening = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
|
||||
const context = ref<ContainerFollowupContext | null>(null)
|
||||
const changedCount = computed(() => rows.value.filter(row => row.impact?.status === 'OPEN').length)
|
||||
const displayedRows = computed(() => onlyChanges.value ? rows.value.filter(row => row.impact?.status === 'OPEN') : rows.value)
|
||||
async function refreshForecasts() {
|
||||
refreshing.value = true; contextError.value = null
|
||||
try { await refreshImpacts({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }); await load() }
|
||||
catch (cause) { contextError.value = getApiErrorDetails(cause, '船期核对未完成,原记录保留') }
|
||||
finally { refreshing.value = false }
|
||||
}
|
||||
let request = 0, contextRequest = 0, controller: AbortController | undefined
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 130, fixed: 'left' },
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 200, fixed: 'left' },
|
||||
{ prop: 'containerNo', label: '集装箱号', minWidth: 170 },
|
||||
{ prop: 'orderCount', label: '美国范围票数', width: 120 },
|
||||
{ prop: 'customerCount', label: '客户数', width: 90 },
|
||||
{ key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 },
|
||||
{ key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 },
|
||||
{ key: 'receiving', label: '接仓点及跟进', slotName: 'receiving', minWidth: 280, singleLine: false },
|
||||
{ key: 'impact', label: '船期跟进', slotName: 'impact', minWidth: 160 },
|
||||
{ key: 'owner', label: '负责人', slotName: 'owner', minWidth: 110 },
|
||||
{ key: 'scope', label: '范围核对', slotName: 'scope', minWidth: 200 },
|
||||
]
|
||||
async function load(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const current = ++request
|
||||
controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
loading.value = true; error.value = null; impactReady.value = false; impactError.value = null
|
||||
try {
|
||||
const result = await listPlanningContainers({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
|
||||
const params = { page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }
|
||||
const [cargo, impacts] = await Promise.allSettled([listPlanningContainers(params, controller.signal), listImpacts(params, controller.signal)])
|
||||
if (current !== request) return
|
||||
rows.value = result.records; total.value = result.total
|
||||
if (cargo.status === 'rejected') throw cargo.reason
|
||||
rows.value = cargo.value.records; total.value = cargo.value.total
|
||||
if (impacts.status === 'fulfilled') {
|
||||
const states = new Map(impacts.value.records.map(row => [row.container.containerBookingId, row.impact]))
|
||||
rows.value = rows.value.map(row => ({ ...row, impact: states.get(row.containerBookingId) || null }))
|
||||
impactReady.value = true
|
||||
} else { onlyChanges.value = false; impactError.value = getApiErrorDetails(impacts.reason, '船期跟进暂不可用') }
|
||||
|
||||
} catch (cause) { if (current === request) { error.value = getApiErrorDetails(cause, '整柜货源加载失败'); rows.value = []; total.value = 0 } }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
@@ -54,22 +76,33 @@ onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="整柜提柜与接仓查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="error" title="整柜货源加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="contextError" title="整柜范围核对失败" :message="contextError.message" :show-retry="false"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
|
||||
<p>同柜多客户只记录一份提柜跟进;接仓按实际仓库分别跟进。预计到港为来源快照,不是对方确认时间。</p>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="displayedRows" :total="total" :loading="loading || refreshing" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="提柜与到仓查询" :fields="fields" :loading="loading" @search="load(true)">
|
||||
<template #actions>
|
||||
<el-button v-if="impactReady" :type="onlyChanges ? 'primary' : 'default'" @click="onlyChanges = !onlyChanges">本页待处理 {{ changedCount }}</el-button>
|
||||
<el-button v-if="impactReady && access.can('platform:appointment:followup')" :loading="refreshing" :disabled="loading" @click="refreshForecasts">核对船期</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button></template>
|
||||
</QueryToolbar></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
|
||||
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
|
||||
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
|
||||
<details v-if="!error" class="container-help"><summary>说明</summary><p>同柜共用提柜跟进,接仓按仓库分别跟进。预计到港仅供参考。</p></details>
|
||||
</template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
|
||||
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
|
||||
<template #arrival="{ row }">{{ displayTime(row.earliestArrivalAt) }}<span v-if="row.latestArrivalAt && row.latestArrivalAt !== row.earliestArrivalAt"> 至 {{ displayTime(row.latestArrivalAt) }}</span></template>
|
||||
<template #receiving="{ row }"><div v-for="warehouse in row.warehouses" :key="warehouse.warehouseId" class="receiving-row"><span>{{ warehouse.warehouseName }} · {{ warehouse.orderCount }} 票</span><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer, warehouse.warehouseId)">接仓跟进</el-button></div></template>
|
||||
<template #impact="{ row }">{{ row.impact ? impactStatus[row.impact.status] : (impactReady ? '未核对' : '暂不可用') }}</template>
|
||||
<template #owner="{ row }">{{ row.impact?.ownerName || '未认领' }}</template>
|
||||
<template #scope="{ row }">{{ row.incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</template>
|
||||
</DataTable>
|
||||
<AppointmentImpactDialog v-if="impactId" :container-id="impactId" :can-write="access.can('platform:appointment:followup')" @close="impactId = null" @saved="load()" />
|
||||
<AppointmentFollowupDialog v-if="context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-help { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.container-help summary { cursor: pointer; }
|
||||
.receiving-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
||||
import { getImpact, handleImpact, type ImpactDetail } from './impactApi'
|
||||
vi.mock('./impactApi', async importOriginal => ({ ...await importOriginal<typeof import('./impactApi')>(), getImpact: vi.fn(), handleImpact: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-TEST', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: null, ownerName: null, baseline: { estimatedAt: '2026-10-15T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, latest: { estimatedAt: '2026-10-17T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, forecastToken: 'current', updatedAt: '2026-10-10T12:00:00' }, cargo: [{ inboundCode: 'IN-A', customerName: '客户甲', packages: 10, destination: 'ONT8' }], appointments: [{ appointmentNo: 'AP-1', status: 'SUCCESS', destination: 'ONT8', confirmedAt: '2026-10-16T09:00:00', timezone: 'America/Los_Angeles' }], transport: null, history: { records: [], total: 0 } }
|
||||
const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button></section>' } } } })
|
||||
describe('appointment impact handling', () => {
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(getImpact).mockResolvedValue(detail) })
|
||||
it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => {
|
||||
const wrapper = render(false); await flushPromises()
|
||||
expect(wrapper.text()).toContain('2026-10-15T09:00:00')
|
||||
expect(wrapper.text()).toContain('2026-10-17T09:00:00')
|
||||
expect(wrapper.text()).toContain('2026-10-16T09:00:00')
|
||||
expect(wrapper.text()).toContain('无车次查看权限')
|
||||
expect(wrapper.text()).not.toContain('认领跟进')
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
|
||||
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(handleImpact).mock.calls[0]![1]
|
||||
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
|
||||
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
|
||||
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
|
||||
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(getImpact).toHaveBeenCalledTimes(2); wrapper.unmount()
|
||||
})
|
||||
it('does not resolve without an actual handling result', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '完成本次核对')!.trigger('click'); await flushPromises()
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getImpact, handleImpact, impactStatus, type ImpactDetail, type ImpactCommand } from './impactApi'
|
||||
const props = defineProps<{ containerId: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ close: []; saved: [] }>()
|
||||
const router = useRouter(), detail = ref<ImpactDetail | null>(null), loading = ref(false), saving = ref(false), historyPage = ref(1)
|
||||
const error = ref<ApiErrorDetails | null>(null), pending = ref<ImpactCommand | null>(null)
|
||||
const form = reactive({ result: '', nextAction: '', followupDate: '' })
|
||||
const actions: Record<string, string> = { OBSERVED: '船期核对', CLAIM: '认领', RECORD: '记录处理', RESOLVE: '完成核对' }
|
||||
async function load() {
|
||||
loading.value = true; error.value = null
|
||||
try { detail.value = await getImpact(props.containerId, historyPage.value) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '影响详情加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save(action: ImpactCommand['action']) {
|
||||
if (!detail.value || saving.value || !props.canWrite) return
|
||||
if (!pending.value && action !== 'CLAIM' && !form.result.trim()) { ElMessage.warning('请填写实际处理结果'); return }
|
||||
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
|
||||
saving.value = true; error.value = null
|
||||
try { await handleImpact(props.containerId, pending.value); pending.value = null; emit('saved'); historyPage.value = 1; await load() }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function close() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭'); return } emit('close') }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog :model-value="true" title="船期变化影响核对" width="min(1200px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog" @update:model-value="close">
|
||||
<LoadErrorState v-if="error" title="核对未完成" :message="error.message" :show-retry="!pending" @retry="load" />
|
||||
<template v-if="detail">
|
||||
<p>{{ detail.impact.impactNo }} · {{ impactStatus[detail.impact.status] }} · 负责人:{{ detail.impact.ownerName || '未认领' }}</p>
|
||||
<p>基准 ETA:{{ detail.impact.baseline?.estimatedAt || '待核实' }} → 最新 ETA:{{ detail.impact.latest?.estimatedAt || '待核实' }}</p>
|
||||
<p>来源:{{ detail.impact.latest?.providerCode || '待核实' }} · {{ detail.impact.latest?.sourceReference || '—' }} · 观察时间:{{ detail.impact.latest?.observedAt || '—' }}</p>
|
||||
<p>实际到港:{{ detail.impact.latest?.actualAt || '未记录' }}。以上为来源原始时间,时区未核实;不能据此判定跨时区违约。</p>
|
||||
<details open><summary>直接关联货物 {{ detail.cargo.length }} 票</summary><div class="impact-list"><p v-for="(row, index) in detail.cargo" :key="index">{{ row.inboundCode || '未关联入仓码' }} · {{ row.customerName || '—' }} · {{ row.destination || '—' }} · {{ row.packages ?? '待核实' }} 箱</p></div></details>
|
||||
<details open><summary>需复核的收货预约 {{ detail.appointments.length }} 张(确认时间不自动修改)</summary><div class="impact-list"><p v-for="row in detail.appointments" :key="row.appointmentNo">{{ row.appointmentNo }} · {{ row.destination }} · {{ row.confirmedAt || '尚未确认时段' }} {{ row.timezone || '时区待核实' }}</p></div></details>
|
||||
<p v-if="!detail.transport">无车次查看权限,运输影响未加载。</p>
|
||||
<template v-else>
|
||||
<details open><summary>关联车次 {{ detail.transport.trips.length }} 辆</summary><div class="impact-list"><p v-for="row in detail.transport.trips" :key="row.tripNo"><el-button link type="primary" @click="router.push(`/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`)">{{ row.tripNo }}</el-button> · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}</p></div></details>
|
||||
<details><summary>同车外接承诺 {{ detail.transport.sameTripBookings.length }} 票,仅提示运输安排复核</summary><div class="impact-list"><p v-for="row in detail.transport.sameTripBookings" :key="row.bookingNo">{{ row.bookingNo }} · {{ row.customerName || '—' }} · {{ row.tripNo }} · {{ row.destinationLabel || '—' }}</p></div></details>
|
||||
</template>
|
||||
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="实际处理结果"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录已联系、核对或改约的实际结果;此处不会自动改约" /></el-form-item>
|
||||
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写后续处理事项" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="canWrite && detail.impact.status === 'OPEN'">
|
||||
<el-button v-if="!pending" :disabled="saving || loading" @click="save('CLAIM')">认领跟进</el-button>
|
||||
<el-button v-if="!pending" :disabled="saving || loading" @click="save('RECORD')">记录处理</el-button>
|
||||
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
|
||||
</div>
|
||||
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="saving || !!pending" @current-change="load" /></details>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.impact-list { max-height: 220px; overflow-y: auto; overflow-wrap: anywhere; }</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { listImpacts } from './impactApi'
|
||||
vi.mock('./impactApi', async original => ({ ...await original<typeof import('./impactApi')>(), listImpacts: vi.fn(), refreshImpacts: vi.fn() }))
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningContainer, type PlanningSource } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listExternalAppointmentCandidates: vi.fn(), listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
@@ -14,7 +16,13 @@ const cargo: PlanningCargo = { sourceKey: source.sourceKey, customerId: 'A', cus
|
||||
const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }
|
||||
|
||||
describe('appointment tables use actual shared table slots', () => {
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(listImpacts).mockImplementation(async () => {
|
||||
const page = await vi.mocked(listPlanningContainers).mock.results.at(-1)!.value
|
||||
return { total: page.total, records: page.records.map((container: PlanningContainer) => ({ container, impact: container.impact || null })) }
|
||||
})
|
||||
})
|
||||
it('discovers external candidates lazily and mixes original bookings with selected first-leg cargo', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const external: PlanningSource = { ...source, sourceKey: 'BOOKING:B-1', customerId: 'B', orderId: null, inboundCode: null, bookingNo: 'B-1', packages: null }
|
||||
@@ -76,12 +84,28 @@ describe('appointment tables use actual shared table slots', () => {
|
||||
expect(wrapper.text()).toContain('保存前尚未占用货物')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the cargo list usable when impact service is unavailable and never reports a false zero', async () => {
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [] }] })
|
||||
vi.mocked(listImpacts).mockRejectedValueOnce(new Error('Resource not found'))
|
||||
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('CONTAINER-A')
|
||||
expect(wrapper.text()).toContain('船期跟进暂不可用,货柜列表仍可查看')
|
||||
expect(wrapper.text()).not.toContain('本页待处理 0')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('details').every(node => node.attributes('open') === undefined)).toBe(true)
|
||||
expect(listPlanningContainers).toHaveBeenCalledTimes(1); expect(listImpacts).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders one shared container and resolves each receiving warehouse without per-row requests', async () => {
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] })
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ impact: { containerBookingId: 'C1', impactNo: 'AI-T', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: 'A', ownerName: '运营甲', baseline: null, latest: null, forecastToken: 'x', updatedAt: '2026-10-10T00:00:00' }, containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] })
|
||||
vi.mocked(resolvePlanningContainer).mockResolvedValue({ containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId: '100', warehouseName: '洛杉矶仓', canFollowup: true, blockReasons: [] })
|
||||
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(listPlanningContainers).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('本页待处理 1')
|
||||
expect(wrapper.text()).toContain('处理船期变化')
|
||||
expect(wrapper.text()).toContain('运营甲')
|
||||
expect(resolvePlanningContainer).not.toHaveBeenCalled()
|
||||
const rows = wrapper.findAll('.el-table__body-wrapper .el-table__row')
|
||||
expect(rows).toHaveLength(1)
|
||||
|
||||
@@ -23,9 +23,16 @@
|
||||
| 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 |
|
||||
| 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 |
|
||||
| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
|
||||
| 船期变化影响与处理 | 统一方案第十节场景16,旧/新预测、下游影响、负责人及结果闭环 | 并入提柜与到仓的货柜列表,不设第四工作视图;行内处理船期变化,本页待办计数明确标识范围;显式本页批量核对,不逐柜轮询。1200px居中详情展示直接货物/预约、关联车次与同车待复核承诺;各明细220px内滚动,历史服务端分页。认领及结果表单复用现有控件;未知提交保留原命令并禁止关闭,已确认预约不因核对动作改变。待1920×1080真实验收 |
|
||||
|
||||
结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约、到港及有权限的提柜、车次来源各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。
|
||||
|
||||
本轮视觉验收:待人工1920×1080截图确认。
|
||||
|
||||
视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
|
||||
|
||||
按用户确认收敛:主视图仅提柜与到仓、送仓与排车、预约日历,默认有权限的列表视图;日历首次进入才加载。到港参考、用车计划默认隐藏,可独立开关,不计入预约数量;缺国家资料与查询超限分开提示。送仓与排车目前仍以选货合批为主,目的仓货量汇总及7/14/30天窗口尚未完成,不因标题调整宣称完成。
|
||||
|
||||
用户截图密度修正:业务标题复用density-detail-title并与三视图同行,不独占大标题区;船期操作并入查询栏,不单独占空白工具栏。常驻业务解释改为默认折叠“说明”;错误保留重试,诊断信息默认折叠。基础货柜与船期状态独立读取,后者失败不阻断货柜列表、不展示假的零待办。
|
||||
|
||||
用户最新截图确认:移除“美国预约总览”文字,仅保留三视图导航;日历图例和图层开关归入有内边距的辅助栏,长范围提示默认折叠,月格112px仍容纳3条摘要及剩余数;右侧固定360px,系统单号/完整时段/备注跨整行。该项以当前用户要求覆盖原标题建议,待1920×1080实际页面确认。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PlanningContainer } from './planningApi'
|
||||
|
||||
export interface ForecastFact { estimatedAt: string | null; actualAt: string | null; observedAt: string | null; providerCode: string | null; sourceReference: string | null }
|
||||
export interface Impact { containerBookingId: string; impactNo: string; version: number; status: string; reason: string | null; ownerName: string | null; ownerId: string | null; baseline: ForecastFact | null; latest: ForecastFact | null; forecastToken: string; updatedAt: string }
|
||||
export interface ImpactRow { container: PlanningContainer; impact: Impact | null }
|
||||
export interface ImpactDetail {
|
||||
impact: Impact
|
||||
cargo: Array<{ inboundCode: string | null; customerName: string | null; packages: number | null; destination: string | null }>
|
||||
appointments: Array<{ appointmentNo: string; status: string; destination: string; confirmedAt: string | null; timezone: string | null }>
|
||||
transport: { trips: Array<{ tripNo: string; status: string; plannedDepartureAt: string | null }>; sameTripBookings: Array<{ tripNo: string; bookingNo: string; customerName: string | null; destinationLabel: string | null; status: string }> } | null
|
||||
history: { records: Array<{ version: number; action: string; operatorName: string | null; result: string | null; nextAction: string | null; followupDate: string | null; createdAt: string }>; total: number }
|
||||
}
|
||||
export interface ImpactCommand { expectedVersion: number; forecastToken: string; idempotencyKey: string; action: 'CLAIM' | 'RECORD' | 'RESOLVE'; result: string | null; nextAction: string | null; followupDate: string | null }
|
||||
const base = '/platform/v1/last-leg/appointment-planning/impacts'
|
||||
export async function listImpacts(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: ImpactRow[]; total: number }>, ApiEnvelope<{ records: ImpactRow[]; total: number }>>(base, { params: query, signal }))
|
||||
return { ...result, total: Number(result.total) }
|
||||
}
|
||||
export async function refreshImpacts(query: { page: number; size: number; keyword?: string }) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<Impact[]>, ApiEnvelope<Impact[]>>(`${base}/refresh`, query))
|
||||
}
|
||||
export async function getImpact(id: string, page = 1) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<ImpactDetail>, ApiEnvelope<ImpactDetail>>(`${base}/${encodeURIComponent(id)}`, { params: { page, size: 20 } }))
|
||||
return { ...result, impact: { ...result.impact, version: Number(result.impact.version) }, history: { ...result.history, total: Number(result.history.total) } }
|
||||
}
|
||||
export async function handleImpact(id: string, command: ImpactCommand) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<Impact>, ApiEnvelope<Impact>>(`${base}/${encodeURIComponent(id)}/commands`, command))
|
||||
}
|
||||
export const impactStatus: Record<string, string> = { OPEN: '待处理', RESOLVED: '已核对处理', MONITORING: '暂无变化' }
|
||||
export const impactReason: Record<string, string> = { FORECAST_MISSING: '到港预测待核实', FORECAST_CHANGED: '到港预测变化', ACTUAL_ARRIVAL_REVIEW: '实际到港已更新', SOURCE_CHANGED: '预测来源变化', UNCHANGED: '预测未变', BASELINE_ESTABLISHED: '已建立核对基准' }
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listPlanningCargo } from './planningApi'
|
||||
import { listPlanningCargo, listPlanningContainers } from './planningApi'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
|
||||
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
|
||||
@@ -12,3 +12,11 @@ it('uses the gateway planning route and preserves stable source IDs and null pre
|
||||
expect(result.records[0]!.estimatedArrivalAt).toBeNull()
|
||||
expect(result.total).toBe(21)
|
||||
})
|
||||
|
||||
it('keeps basic container listing on its own endpoint rather than requiring impact persistence', async () => {
|
||||
vi.mocked(http.get).mockClear()
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [], total: '0' } })
|
||||
const result = await listPlanningContainers({ page: 1, size: 20 })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function resolvePlanningSource(query: { sourceKey: string; customer
|
||||
}
|
||||
|
||||
export interface PlanningContainer {
|
||||
impact?: import('./impactApi').Impact | null
|
||||
containerBookingId: string; containerNo: string | null; orderCount: number; customerCount: number;
|
||||
packages: number | null; unknownPackageOrders: number; earliestArrivalAt: string | null; latestArrivalAt: string | null;
|
||||
standardNode: string; sourceVersion: string | null; incompleteScope: boolean;
|
||||
@@ -118,6 +119,7 @@ export async function listPlanningContainers(query: { page: number; size: number
|
||||
warehouses: row.warehouses.map(warehouse => ({ ...warehouse, orderCount: Number(warehouse.orderCount), customerCount: Number(warehouse.customerCount) })),
|
||||
})) }
|
||||
}
|
||||
|
||||
export async function resolvePlanningContainer(containerId: string, warehouseId?: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<ContainerFollowupContext>, ApiEnvelope<ContainerFollowupContext>>(`${containerBase}/${encodeURIComponent(containerId)}`, { params: { warehouseId } }))
|
||||
}
|
||||
|
||||
@@ -240,9 +240,26 @@ describe('procurement purchase node operations', () => {
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(document.body.textContent).toContain('订单版本冲突'); expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
})
|
||||
it('hides actions without permission and disables actions blocked by the server', async () => {
|
||||
it('hides unauthorized actions and preserves blocked node reasons without a submit button', async () => {
|
||||
let wrapper = render(fixture(), []); expect(wrapper.find('.purchase-actions button').exists()).toBe(false); wrapper.unmount()
|
||||
wrapper = render(fixture('REVIEW', false)); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined()
|
||||
wrapper = render(fixture('REVIEW', false))
|
||||
expect(wrapper.find('.purchase-actions button').exists()).toBe(false)
|
||||
expect(wrapper.find('details').text()).toContain('审核采购')
|
||||
expect(wrapper.find('details').text()).toContain('业务条件不满足')
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps executable workflow actions in front and financial exceptions in a closed group', () => {
|
||||
const detail = fixture('PURCHASE')
|
||||
detail.actions.push(...fixture('FINANCE_ADJUST').actions, ...fixture('PACK', false).actions)
|
||||
const wrapper = render(detail)
|
||||
const primary = wrapper.get('[data-testid="current-procurement-actions"]')
|
||||
expect(primary.text()).toContain('登记购买完成')
|
||||
expect(primary.text()).not.toContain('费用更正与退款')
|
||||
expect(primary.text()).not.toContain('保存装箱草稿')
|
||||
const groups = wrapper.findAll('details')
|
||||
expect(groups[0].text()).toContain('费用更正与退款')
|
||||
expect(groups[0].attributes('open')).toBeUndefined()
|
||||
expect(groups[1].text()).toContain('业务条件不满足')
|
||||
})
|
||||
it('prevents a second submit while the first command is pending', async () => {
|
||||
let complete!: () => void; vi.mocked(submitProcurementCommand).mockImplementation(() => new Promise(resolve => { complete = resolve }))
|
||||
|
||||
@@ -28,6 +28,10 @@ const auth = useAuthStore(); const access = useAccessPreviewStore()
|
||||
const routes: Record<string, ProcurementCommand> = { REVIEW: 'review', PURCHASE: 'purchase', CONFIRM_PURCHASE: 'purchase/confirm', AUTHORIZE_BUDGET: 'budget-authorizations', PLAN_WAREHOUSE: 'planned-warehouse', CANCEL: 'cancel', RECEIVE_OVERSEAS: 'overseas-receipts', RECEIVE_DOMESTIC: 'domestic-receipts', FINISH_OVERSEAS: 'overseas-receipts/complete', FINISH_DOMESTIC: 'domestic-receipts/complete', PACK: 'packing', CONFIRM_PACKING: 'packing/confirm', MANAGE: 'dispositions', LABELS: 'labels', ESTIMATE: 'international-estimate', APPLY_LABELS: 'labels/apply', SHIP_OVERSEAS: 'overseas-shipment', SHIP_CUSTOMER: 'customer-shipment', REPACK: 'domestic-repack', CONFIRM_CHARGES: 'charges/confirm', FINANCE_ADJUST: 'fees/correct-target', RETRY_FEE: 'fees/retry', EXCEPTION_CLOSE: 'exception-close' }
|
||||
const retryFees = computed(() => props.detail.fees.filter(fee => fee.active && fee.walletStatus.endsWith('_FAILED') && access.can(feeRetryPermission(fee))))
|
||||
const actions = computed(() => [...props.detail.actions.filter(item => routes[item.key] && access.can(item.permission)), ...(retryFees.value.length ? [{ key: 'RETRY_FEE', label: '重试失败资金请求', permission: feeRetryPermission(retryFees.value[0]), allowed: true, reason: null }] : [])])
|
||||
const secondaryKeys = new Set(['AUTHORIZE_BUDGET', 'PLAN_WAREHOUSE', 'MANAGE', 'FINANCE_ADJUST', 'CANCEL', 'EXCEPTION_CLOSE'])
|
||||
const currentActions = computed(() => actions.value.filter(action => action.allowed && !secondaryKeys.has(action.key)))
|
||||
const secondaryActions = computed(() => actions.value.filter(action => secondaryKeys.has(action.key)))
|
||||
const unavailableActions = computed(() => actions.value.filter(action => !action.allowed && !secondaryKeys.has(action.key)))
|
||||
const opened = ref(false); const active = ref<ProcurementAction | null>(null); const saving = ref(false); const uploading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); const pending = ref<PendingProcurement | null>(null)
|
||||
const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('')
|
||||
@@ -183,8 +187,18 @@ async function upload(event: Event) {
|
||||
</script>
|
||||
<template>
|
||||
<section class="admin-density" aria-label="采购履约节点操作">
|
||||
<div class="density-actions purchase-actions"><el-tooltip v-for="action in actions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!action.allowed || !!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip></div>
|
||||
<el-button v-if="canDraft" :disabled="saving || uploading" @click="openCorrectionDraft">{{ correctionDraft ? '恢复费用更正草稿' : '准备费用更正草稿' }}</el-button><el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
|
||||
<div class="density-actions purchase-actions" data-testid="current-procurement-actions"><el-tooltip v-for="action in currentActions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip></div>
|
||||
<details v-if="secondaryActions.length || canDraft" class="purchase-secondary-actions">
|
||||
<summary>更正、财务及异常处理</summary>
|
||||
<div class="density-actions purchase-actions"><el-tooltip v-for="action in secondaryActions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!action.allowed || !!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip>
|
||||
<el-button v-if="canDraft" :disabled="saving || uploading" @click="openCorrectionDraft">{{ correctionDraft ? '恢复费用更正草稿' : '准备费用更正草稿' }}</el-button>
|
||||
</div>
|
||||
</details>
|
||||
<details v-if="unavailableActions.length" class="purchase-secondary-actions">
|
||||
<summary>其他履约节点及不可操作原因</summary>
|
||||
<dl><template v-for="action in unavailableActions" :key="action.key"><dt>{{ action.label }}</dt><dd>{{ action.reason || '当前不可操作' }}</dd></template></dl>
|
||||
</details>
|
||||
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
|
||||
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
|
||||
<ApiErrorMeta v-if="error && !opened" :details="error" />
|
||||
<AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
|
||||
|
||||
@@ -146,3 +146,5 @@
|
||||
| 车次准备 | 选择合格排车、新建或加入车次、连续发布 | 1080px居中,复用车次与发布表单 | 来源带入、版本和失败续办,未授权的预约事实不由此生成 |
|
||||
|
||||
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
|
||||
|
||||
2026-10-10 拼车简化收口:调度默认待我处理,销售默认可拼车次;登记仍为1080px居中弹框,默认只展开客户和卸货站,已知货量与确认前资料分区展开;只读跟进仅突出当前有权限的主要动作,其他操作与关联单据收起。沿原请求、权限与真实作业节点;本次视觉变更待1920×1080、100%缩放人工截图确认。
|
||||
|
||||
@@ -16,6 +16,27 @@ const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
|
||||
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
|
||||
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const followupActions = computed(() => {
|
||||
const row = props.inquiry
|
||||
if (!row) return []
|
||||
const manage = access.can('platform:fleet:capacity-offer:manage')
|
||||
return [
|
||||
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && (manage || access.can('platform:fleet:capacity-offer:inquiry-edit')) },
|
||||
{ key: 'confirm', label: '核对并确认', visible: manage && (row.actions.canEdit || row.actions.canConfirm) },
|
||||
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit') },
|
||||
{ key: 'receipt', label: '核对实收与运费', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm') },
|
||||
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch') },
|
||||
{ key: 'fulfillment', label: '查看收货进度', visible: !!row.incomingNo },
|
||||
{ key: 'dispatch', label: '查看排车详情', visible: !!row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list') },
|
||||
{ key: 'trip', label: '前往车次审核', visible: !!row.dispatchNo && access.can('platform:fleet:trip:list') },
|
||||
].filter(action => action.visible)
|
||||
})
|
||||
const primaryFollowup = computed(() => {
|
||||
const key = props.inquiry?.progress.primaryAction
|
||||
if (key === 'VIEW') return followupActions.value.find(action => action.key === (props.inquiry?.dispatchNo ? 'dispatch' : 'fulfillment'))
|
||||
return followupActions.value.find(action => action.key.toUpperCase() === key)
|
||||
})
|
||||
const secondaryFollowups = computed(() => followupActions.value.filter(action => action !== primaryFollowup.value))
|
||||
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
|
||||
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
|
||||
@@ -86,19 +107,29 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
|
||||
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
|
||||
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
|
||||
<p v-else>保存后调度可见,尚不保位。已知货量可先填,未知可留空,确认承运前再补齐。</p>
|
||||
<p v-else>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p>
|
||||
<p v-if="!inquiry">业务类型:{{ form.businessType === 'US_TRUCK' ? '本土卡车' : '本土散板' }},可在“确认承运前待补资料”中调整。</p>
|
||||
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
|
||||
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
|
||||
<section v-if="inquiry && readonly" aria-label="继续处理本票货物">
|
||||
<el-button v-if="primaryFollowup" type="primary" @click="emit('action', primaryFollowup.key, inquiry)">{{ primaryFollowup.label }}</el-button>
|
||||
<details v-if="secondaryFollowups.length"><summary>其他操作与关联单据</summary><div class="density-actions">
|
||||
<el-button v-for="action in secondaryFollowups" :key="action.key" @click="emit('action', action.key, inquiry)">{{ action.label }}</el-button>
|
||||
</div></details>
|
||||
</section>
|
||||
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
|
||||
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
|
||||
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>已知货量(可先填写,未知可留空)</summary><div class="capacity-inquiry-form">
|
||||
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
|
||||
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
</div></details>
|
||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
|
||||
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
||||
|
||||
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="填写货物名称(确认承运前必填)" /></el-form-item>
|
||||
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
|
||||
<el-form-item label="预计到仓"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
@@ -118,16 +149,6 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
<p v-if="quoteSearched && !quoteRows.length">暂无适用报价,可继续手工询价。</p><div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
|
||||
</template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p>
|
||||
</section></details>
|
||||
<section v-if="inquiry && readonly" aria-label="继续处理本票货物" class="density-actions">
|
||||
<el-button v-if="inquiry.actions.canEdit" @click="emit('action', 'edit', inquiry)">补充资料</el-button>
|
||||
<el-button v-if="access.can('platform:fleet:capacity-offer:manage') && (inquiry.actions.canEdit || inquiry.actions.canConfirm)" @click="emit('action', 'confirm', inquiry)">核对并确认</el-button>
|
||||
<el-button v-if="inquiry.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit')" @click="emit('action', 'incoming', inquiry)">安排来货</el-button>
|
||||
<el-button v-if="inquiry.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-confirm')" @click="emit('action', 'receipt', inquiry)">核对实收与运费</el-button>
|
||||
<el-button v-if="inquiry.progress.primaryAction === 'BIND' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch')" @click="emit('action', 'bind', inquiry)">确认排入本车</el-button>
|
||||
<el-button v-if="inquiry.incomingNo" @click="emit('action', 'fulfillment', inquiry)">查看收货进度</el-button>
|
||||
<el-button v-if="inquiry.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('action', 'dispatch', inquiry)">查看排车详情</el-button>
|
||||
<el-button v-if="inquiry.dispatchNo && access.can('platform:fleet:trip:list')" @click="emit('action', 'trip', inquiry)">前往车次审核</el-button>
|
||||
</section>
|
||||
<section v-if="inquiry?.appointmentCoverage" aria-label="收货预约覆盖">
|
||||
<h3>收货预约覆盖</h3><el-button v-if="access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')" @click="emit('action', 'appointment', inquiry)">预约跟进</el-button><p>{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。</p>
|
||||
<p v-if="inquiry.appointmentCoverage.missingFields.length">待处理:{{ inquiry.appointmentCoverage.missingFields.map(key => appointmentMissingLabels[key] || '请预约人员核对来源资料').join('、') }}</p>
|
||||
|
||||
@@ -21,7 +21,7 @@ const pageOf = <T,>(records: T[], total = records.length) => ({ records, total,
|
||||
const wrappers: VueWrapper[] = []
|
||||
const layout = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '<input />' }
|
||||
function mount(component: object, props: Record<string, unknown> = {}) {
|
||||
function mount(component: object, props: Record<string, unknown> = {}): { wrapper: VueWrapper; pinia: ReturnType<typeof createPinia> } {
|
||||
const pinia = createPinia()
|
||||
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
|
||||
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
|
||||
@@ -69,7 +69,7 @@ it('reads detail once, permits drafting while capacity is unknown, and never pre
|
||||
it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => {
|
||||
vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true })
|
||||
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', inquiry.customerId); selects[2]!.vm.$emit('update:modelValue', inquiry.cargo.destinationKey); await flushPromises()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', inquiry.customerId); selects[1]!.vm.$emit('update:modelValue', inquiry.cargo.destinationKey); await flushPromises()
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.saveCapacityInquiry).mock.calls[0]![2]
|
||||
expect(first).toMatchObject({ expectedPalletCount: null, cargo: { packageCount: null, palletSpaces: null }, idempotencyKey: expect.any(String) })
|
||||
@@ -136,10 +136,14 @@ it('keeps the create button disabled with a reason after cutoff, while hiding it
|
||||
const readonly = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
|
||||
})
|
||||
it('defaults administrators to available offers and requires an explicit history filter', async () => {
|
||||
it('defaults minimal managers to their TODO inquiries without loading offers, and explicitly switches to available offers', async () => {
|
||||
state.manage = true
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'TODO' }))
|
||||
expect(api.listCapacityOffers).not.toHaveBeenCalled()
|
||||
expect(state.links.size).toBe(0)
|
||||
await button(wrapper, '可拼车次').trigger('click'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
||||
const query = wrapper.findComponent({ name: 'QueryToolbar' })
|
||||
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
|
||||
@@ -191,3 +195,31 @@ it('shows follow-up destinations only with the matching read permission', async
|
||||
expect(second.wrapper.emitted('action')).toEqual([['trip', row]])
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps initial inputs compact, reveals confirmation fields, and keeps unknown amounts null', async () => {
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false })
|
||||
await flushPromises()
|
||||
const visibleInputs = wrapper.findAll('input').filter(input => !input.element.closest('details:not([open])'))
|
||||
expect(visibleInputs).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('业务类型:本土散板')
|
||||
const cargo = wrapper.findAll('details').find(section => section.find('summary').text().startsWith('已知货量'))!
|
||||
expect(cargo.attributes('open')).toBeUndefined()
|
||||
await wrapper.setProps({ confirmMode: true }); await flushPromises()
|
||||
expect(cargo.attributes('open')).toBeDefined()
|
||||
expect(wrapper.findAll('details').find(section => section.find('summary').text().startsWith('确认承运前待补资料'))!.attributes('open')).toBeDefined()
|
||||
})
|
||||
it('highlights only the allowed current step and keeps other actions secondary', async () => {
|
||||
state.manage = true
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry, readonly: true })
|
||||
await flushPromises()
|
||||
const section = wrapper.find('[aria-label="继续处理本票货物"]')
|
||||
expect(section.findAll('button').filter(item => !item.element.closest('details')).map(item => item.text())).toEqual(['补充资料'])
|
||||
expect(section.find('details').text()).toContain('核对并确认')
|
||||
await button(wrapper, '补充资料').trigger('click')
|
||||
expect(wrapper.emitted('action')?.[0]).toEqual(['edit', inquiry])
|
||||
await wrapper.setProps({ inquiry: { ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' }, actions: { ...inquiry.actions, canEdit: false, canConfirm: false } } })
|
||||
expect(section.findAll('button').filter(item => !item.element.closest('details'))).toHaveLength(0)
|
||||
state.links.add('platform:fleet:trip:booking-edit')
|
||||
await wrapper.setProps({ inquiry: { ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' } } })
|
||||
expect(section.findAll('button').filter(item => !item.element.closest('details')).map(item => item.text())).toEqual(['安排来货'])
|
||||
})
|
||||
|
||||
@@ -17,7 +17,9 @@ import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacit
|
||||
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
|
||||
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const tab = ref<'offers' | 'inquiries'>('offers'), filters = ref<QueryModel>({}), applied = ref<QueryModel>({})
|
||||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const tab = ref<'offers' | 'inquiries'>(manage.value ? 'inquiries' : 'offers')
|
||||
const filters = ref<QueryModel>(manage.value ? { scope: 'TODO' } : {}), applied = ref<QueryModel>({ ...filters.value })
|
||||
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
|
||||
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
|
||||
@@ -33,7 +35,6 @@ function openFulfillment(key: string, row: CapacityInquiry) {
|
||||
return true
|
||||
}
|
||||
let sequence = 0, editorSequence = 0
|
||||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
||||
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
|
||||
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
|
||||
|
||||
Reference in New Issue
Block a user