feat(admin): streamline appointments and fleet intake

This commit is contained in:
2026-10-09 22:05:51 +08:00
parent 88dc4852c6
commit e1457a8e57
39 changed files with 1215 additions and 165 deletions
@@ -50,4 +50,18 @@ describe('appointment calendar interactions', () => {
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
})
it('renders many inbound codes outside the two-column fields with only three previews', async () => {
const wrapper = render()
await wrapper.setProps({ events: [{
key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
}] })
const card = wrapper.get('.detail-event')
expect(card.get('dl').text()).not.toContain('WT-INBOUND')
expect(card.findAll('.inbound-codes li')).toHaveLength(3)
expect(card.get('.inbound-codes').text()).toContain('20 个')
expect(card.get('footer').text()).toContain('编辑')
})
})
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
export interface CalendarEvent {
@@ -11,6 +12,7 @@ export interface CalendarEvent {
title: string
subtitle?: string
fields: Array<{ label: string; value?: string | null }>
inboundCodes?: Array<string | null | undefined>
canEdit?: boolean
canDelete?: boolean
}
@@ -18,9 +20,10 @@ export interface CalendarEvent {
const props = withDefaults(defineProps<{
events: CalendarEvent[]
loading?: boolean
incomplete?: boolean
canCreate?: boolean
initialDate?: string
}>(), { loading: false, canCreate: false, initialDate: '' })
}>(), { loading: false, incomplete: false, canCreate: false, initialDate: '' })
const emit = defineEmits<{
range: [range: { from: string; to: string }]
create: [date: string]
@@ -84,10 +87,10 @@ defineExpose({ selectedDate, mode })
<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},共 ${(grouped.get(date) || []).length} 条安排`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排`" :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} · 当天共 ${(grouped.get(date) || []).length} 条安排`">
<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>
@@ -96,13 +99,14 @@ defineExpose({ selectedDate, mode })
</div>
</div>
<aside class="calendar-detail" aria-label="当天安排">
<header><strong>{{ selectedDate }}</strong><p>当天共有 <b>{{ selectedEvents.length }}</b> 项安排</p></header>
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分来源未加载)</span></p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">当天暂无到港或送仓预约</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">{{ event.type === 'arrival' ? '船舶到港' : '预约送仓' }}</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>
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
<footer v-if="event.canEdit || event.canDelete" class="density-actions">
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">删除</el-button>
@@ -3,14 +3,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
import { listArrivalCalendar } from './api'
import { resolvePlanningSource, saveFollowup } from './planningApi'
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true }))
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn() }))
import { listPlatformWarehouses } from '@/modules/warehouses'
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), deleteAppointment: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'source-read' || routing.canSourceRead }) }))
vi.mock('@/modules/overseas-warehouse', () => ({
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
overseasWarehousePermissions: { inboundOrderAppointment: 'write' },
overseasWarehousePermissions: { inboundOrderAppointment: 'write', lastMileOrderList: 'source-read' },
}))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
@@ -21,22 +28,25 @@ const candidates = [
function render() {
return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
AdminPage: { template: '<main><slot /></main>' },
AppointmentCalendarPanel: { template: '<button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', 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>' },
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElDatePicker: { props: ['modelValue'], template: '<input data-date :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElButton: { template: '<button><slot /></button>' },
ElSelect: true, ElOption: true,
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions" /><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
LoadErrorState: true, ApiErrorMeta: true,
ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><option value="">请选择</option><slot /></select>' },
ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
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,
} } })
}
describe('appointment calendar data integration', () => {
beforeEach(() => {
vi.resetAllMocks()
routing.query = {}; routing.canSourceRead = true
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 })
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
vi.mocked(listArrivalCalendar).mockResolvedValue([])
vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
@@ -50,12 +60,12 @@ describe('appointment calendar data integration', () => {
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
expect(listInboundOrders).not.toHaveBeenCalled()
await wrapper.get('[data-create]').trigger('click')
await wrapper.get('input[placeholder="例如 ONT8"]').setValue('ONT8')
await wrapper.findAll('button').find(button => button.text() === '查询可预约货物')!.trigger('click')
await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
await flushPromises()
await wrapper.get('[data-select]').trigger('click')
await wrapper.get('input[placeholder="请输入预约编号"]').setValue('APT-AB')
await wrapper.findAll('[data-date]')[2]!.setValue('2026-10-09T07:00:00')
await wrapper.get('input[placeholder="填写仓库提供的预约编号"]').setValue('APT-AB')
await wrapper.findAll('[data-date]')[1]!.setValue('2026-10-09T07:00:00')
await wrapper.get('[data-save]').trigger('click')
await flushPromises()
expect(createFbaAppointment).toHaveBeenCalledTimes(1)
@@ -66,4 +76,112 @@ describe('appointment calendar data integration', () => {
expect(listInboundOrders).toHaveBeenCalledTimes(1)
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2)
})
it('only searches active FBA warehouses and loads cargo automatically after selection', async () => {
const wrapper = render()
await wrapper.get('[data-create]').trigger('click')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('查询可预约货物')
expect(wrapper.find('details').attributes('open')).toBeUndefined()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
await flushPromises()
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
})
it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => {
const wrapper = render()
await wrapper.get('[data-create]').trigger('click')
await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
await flushPromises()
await wrapper.get('[data-select]').trigger('click')
await wrapper.get('input[placeholder="填写仓库提供的预约编号"]').setValue('APT-AB')
await wrapper.findAll('[data-date]')[1]!.setValue('2026-10-09T07:00:00')
await wrapper.get('select[aria-label="预约仓库"]').setValue('')
await wrapper.get('[data-save]').trigger('click')
expect(createFbaAppointment).not.toHaveBeenCalled()
expect(listInboundOrders).toHaveBeenCalledTimes(1)
expect(wrapper.find('[data-select]').exists()).toBe(false)
})
it('discards cargo responses from the previously selected warehouse', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof listInboundOrders>>) => void
vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = render()
await wrapper.get('[data-create]').trigger('click')
await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
vi.mocked(listInboundOrders).mockResolvedValueOnce({ records: [], total: 0, page: 1, size: 10, pages: 0 })
await wrapper.get('select[aria-label="预约仓库"]').setValue('LAX9')
await flushPromises()
resolveOld({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
})
it('searches by keyword and exposes warehouse failures without accepting free text', async () => {
const wrapper = render()
await wrapper.get('[data-create]').trigger('click')
await flushPromises()
const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))!
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('仓库暂不可用'))
await select.props('remoteMethod')(' LAX ')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' }))
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('仓库加载失败')
expect(wrapper.findAll('select[aria-label="预约仓库"] option')).toHaveLength(1)
expect(listInboundOrders).not.toHaveBeenCalled()
})
it('keeps available arrivals and marks the result incomplete when appointments fail', async () => {
vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用'))
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', bookingNo: 'C-1', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
const wrapper = render()
await flushPromises()
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('incomplete')).toBe(true)
expect(calendar.props('events')).toHaveLength(1)
expect(calendar.props('events')[0].type).toBe('arrival')
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('送仓预约加载失败')
})
it('keeps delivery appointments when the arrival source fails', async () => {
vi.mocked(listArrivalCalendar).mockRejectedValueOnce(new Error('到港暂不可用'))
vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', appointmentAt: '2026-10-09T09:00:00', expectedLoadingAt: '2026-10-09T07:00:00', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }])
const wrapper = render()
await flushPromises()
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('incomplete')).toBe(true)
expect(calendar.props('events')[0].key).toBe('APT-AB')
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('船舶到港加载失败')
})
it('resolves an external booking deep link through the backend before opening followup', async () => {
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }
vi.mocked(resolvePlanningSource).mockResolvedValueOnce({ source: { sourceKey: 'BOOKING:B-1', customerId: 'A', orderId: null, inboundCode: null, bookingNo: 'B-1', containerNo: null, originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: 'BOOKING:1', canFollowup: true, blockReasons: [] }, coverage: { status: 'SOURCE_NOT_MATERIALIZED', appointments: [], missingFields: ['REAL_CARGO_MATERIALS'], changeReviewRequired: false } })
const wrapper = render(); await flushPromises()
expect(resolvePlanningSource).toHaveBeenCalledWith(routing.query)
const dialog = wrapper.findComponent({ name: 'AppointmentFollowupDialog' })
expect(dialog.props('source').bookingNo).toBe('B-1')
expect(dialog.props('source').orderId).toBeNull()
expect(dialog.props('coverage').status).toBe('SOURCE_NOT_MATERIALIZED')
expect(createFbaAppointment).not.toHaveBeenCalled(); expect(saveFollowup).not.toHaveBeenCalled()
})
it('does not trust stale destination query data or open followup after a source error', async () => {
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:OLD' }
vi.mocked(resolvePlanningSource).mockRejectedValueOnce(new Error('目的地已变化'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('预约货源核对失败')
})
it('does not request deep-link cargo without the underlying source permission', async () => {
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }; routing.canSourceRead = false
const wrapper = render(); await flushPromises()
expect(resolvePlanningSource).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
})
})
@@ -1,6 +1,9 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -14,16 +17,26 @@ import {
overseasWarehousePermissions as permission,
type FbaAppointmentDto, type BatchFbaAppointmentPayload, type InboundOrderSummaryDto,
} from '@/modules/overseas-warehouse'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import { calendarRange, dateKey } from './calendar'
import { deleteAppointment, listArrivalCalendar, type ArrivalCalendarEntry } from './api'
const access = useAccessPreviewStore()
const route = useRoute()
const linkedSource = ref<ResolvedPlanningSource | null>(null)
const linkError = ref<ApiErrorDetails | null>(null)
const linkLoading = ref(false)
let linkRequest = 0
const workView = ref<'calendar' | 'cargo'>('calendar')
const appointments = ref<FbaAppointmentDto[]>([])
const arrivals = ref<ArrivalCalendarEntry[]>([])
const range = ref(calendarRange(dateKey(new Date()), 'month'))
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const sourceErrors = ref<Array<{ title: string; details: ApiErrorDetails }>>([])
const errorTitle = ref('预约日历加载失败')
const dialogError = ref<ApiErrorDetails | null>(null)
const dialogOpen = ref(false)
@@ -32,6 +45,12 @@ const detailLoading = ref(false)
const detailReady = ref(false)
const editing = ref<FbaAppointmentDto | null>(null)
const warehouse = ref('')
const warehouseOptions = ref<PlatformWarehouseSummaryResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
const warehouseKeyword = ref('')
const warehouseTotal = ref(0)
let warehouseRequest = 0
const candidates = ref<InboundOrderSummaryDto[]>([])
const selected = ref<InboundOrderSummaryDto[]>([])
const candidateLoading = ref(false)
@@ -76,9 +95,9 @@ const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival =>
{ label: '预约编号', value: appointment.appointmentNo },
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
{ label: '入仓码', value: appointment.items.map(item => item.inboundCode).filter(Boolean).join('、') },
{ label: '预约备注', value: appointment.appointmentRemark },
],
inboundCodes: appointment.items.map(item => item.inboundCode),
canEdit: access.can(permission.inboundOrderAppointment),
canDelete: access.can('platform:appointment:delete'),
}))])
@@ -90,21 +109,31 @@ async function load(nextRange = range.value) {
calendarController = new AbortController()
loading.value = true
loadError.value = null
sourceErrors.value = []
errorTitle.value = '预约日历加载失败'
appointments.value = []
arrivals.value = []
try {
const [result, arrivalResult] = await Promise.all([
const [result, arrivalResult] = await Promise.allSettled([
listFbaAppointmentCalendar(nextRange, calendarController.signal),
listArrivalCalendar(nextRange, calendarController.signal),
])
if (request === calendarRequest) { appointments.value = result; arrivals.value = arrivalResult }
if (request !== calendarRequest) return
if (result.status === 'fulfilled') appointments.value = result.value
else sourceErrors.value.push({ title: '送仓预约加载失败', details: getApiErrorDetails(result.reason, '送仓预约加载失败') })
if (arrivalResult.status === 'fulfilled') arrivals.value = arrivalResult.value
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
} catch (error) {
if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败')
} finally { if (request === calendarRequest) loading.value = false }
}
function openCreate(date: string) {
++candidateRequest
++detailRequest
detailLoading.value = false
candidateLoading.value = false
candidatePage.value = 1
editing.value = null
warehouse.value = ''
searchedWarehouse.value = ''
@@ -117,6 +146,7 @@ function openCreate(date: string) {
Object.assign(form, { appointmentEndAt: '', appointmentNo: '', appointmentAt: `${date}T09:00:00`, expectedLoadingAt: '', appointmentStatus: 'PENDING', appointmentRemark: '' })
dialogOpen.value = true
detailReady.value = true
void searchWarehouses('')
}
async function openEdit(event: CalendarEvent) {
@@ -141,14 +171,38 @@ async function openEdit(event: CalendarEvent) {
} finally { if (request === detailRequest) detailLoading.value = false }
}
async function loadCandidates(search = false) {
if (search) {
searchedWarehouse.value = warehouse.value.trim().toUpperCase()
candidatePage.value = 1
selected.value = []
selectionEpoch.value++
}
if (!searchedWarehouse.value) { ElMessage.warning('请先输入目的仓编码'); return }
async function searchWarehouses(keyword: string) {
warehouseKeyword.value = keyword
const request = ++warehouseRequest
warehouseLoading.value = true
warehouseError.value = null
warehouseOptions.value = []
warehouseTotal.value = 0
try {
const result = await listPlatformWarehouses({ page: 1, size: 50, keyword: keyword.trim() || undefined, warehouseType: 'FBA', status: 'ACTIVE' })
if (request !== warehouseRequest || !dialogOpen.value) return
warehouseOptions.value = result.records.filter(item => item.warehouseType === 'FBA' && item.status === 'ACTIVE')
warehouseTotal.value = result.total
} catch (error) {
if (request === warehouseRequest) warehouseError.value = getApiErrorDetails(error, '仓库搜索失败,请重试')
} finally { if (request === warehouseRequest) warehouseLoading.value = false }
}
function changeWarehouse(value: string) {
++candidateRequest
candidateLoading.value = false
candidates.value = []
selected.value = []
candidateTotal.value = 0
candidateError.value = null
candidatePage.value = 1
selectionEpoch.value++
searchedWarehouse.value = value
if (value) void loadCandidates()
}
async function loadCandidates() {
if (!searchedWarehouse.value) return
candidateLoading.value = true
candidateError.value = null
const request = ++candidateRequest
@@ -171,6 +225,8 @@ async function save() {
ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return
}
if (!editing.value) {
if (!warehouse.value || warehouse.value !== searchedWarehouse.value || candidateLoading.value || candidateError.value) { ElMessage.warning('请先选择仓库并等待货物加载完成'); return }
if (selected.value.some(item => item.fbaWarehouseCode !== warehouse.value)) { ElMessage.warning('请重新选择当前仓库的货物'); return }
if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return }
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
@@ -212,41 +268,93 @@ async function remove(event: CalendarEvent) {
} finally { loading.value = false }
}
watch(dialogOpen, open => {
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
})
async function resolveLink() {
const request = ++linkRequest
linkedSource.value = null; linkError.value = null; linkLoading.value = false
const { sourceKey, customerId, destinationKey } = route.query
if (!sourceKey && !customerId && !destinationKey) return
if (typeof sourceKey !== 'string' || typeof customerId !== 'string' || typeof destinationKey !== 'string') {
linkError.value = { message: '货物链接不完整,请返回原登记重新打开' }; return
}
if (!access.can(permission.lastMileOrderList)) { linkError.value = { message: '当前账号没有货源读取权限,请联系预约负责人处理' }; return }
linkLoading.value = true
try {
const result = await resolvePlanningSource({ sourceKey, customerId, destinationKey })
if (request === linkRequest) linkedSource.value = result
} catch (cause) { if (request === linkRequest) linkError.value = getApiErrorDetails(cause, '预约货源核对失败') }
finally { if (request === linkRequest) linkLoading.value = false }
}
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
onMounted(() => load())
onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; calendarController?.abort() })
onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
</script>
<template>
<AdminPage class="admin-density">
<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')" @close="linkedSource = null" />
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
<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 === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">已开船 FBA 货物</button>
</div>
<AppointmentCargoPanel v-if="workView === 'cargo'" />
<div v-show="workView === 'calendar'">
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<AppointmentCalendarPanel :events="events" :loading="loading" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" />
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="saving" :confirm-disabled="detailLoading" @confirm="save">
<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>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="sourceErrors.length > 0" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" />
</div>
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" confirm-text="保存" :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>
<div v-loading="detailLoading">
<el-form label-position="top" class="calendar-form density-form">
<el-form-item label="预约送仓时间" required><el-date-picker v-model="form.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预约结束时间"><el-date-picker v-model="form.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预计装车时间" required><el-date-picker v-model="form.expectedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预约仓库" required><el-input v-model="warehouse" :readonly="!!editing" placeholder="例如 ONT8" maxlength="128" /></el-form-item>
<el-form-item label="预约编号" required><el-input v-model="form.appointmentNo" :readonly="!!editing" maxlength="64" placeholder="请输入预约编号" /></el-form-item>
<el-form-item label="预约状态" required><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="预约备注"><el-input v-model="form.appointmentRemark" maxlength="1000" placeholder="请输入预约备注(选填)" /></el-form-item>
<el-form-item label="预约仓库" required>
<el-input v-if="editing" :model-value="warehouse" readonly />
<el-select v-else v-model="warehouse" aria-label="预约仓库" filterable remote clearable :remote-method="searchWarehouses" :loading="warehouseLoading" placeholder="搜索仓库编码或名称" @change="changeWarehouse">
<el-option v-for="item in warehouseOptions" :key="String(item.id)" :value="item.warehouseCode" :label="item.warehouseName === item.warehouseCode ? item.warehouseCode : `${item.warehouseCode} · ${item.warehouseName}`" />
</el-select>
</el-form-item>
<el-form-item label="预约送仓时间" required><el-date-picker v-model="form.appointmentAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预约编号" required><el-input v-model="form.appointmentNo" :readonly="!!editing" maxlength="64" placeholder="填写仓库提供的预约编号" /></el-form-item>
<el-form-item label="预计装车时间" required><el-date-picker v-model="form.expectedLoadingAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item v-if="editing" label="预约状态"><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
</el-form>
<template v-if="!editing">
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
<details :key="editing?.appointmentNo || 'create'" class="appointment-extra">
<summary>补充信息(结束时间、备注{{ editing ? '' : '、状态' }})</summary>
<el-form label-position="top" class="calendar-form density-form">
<el-form-item label="预约结束时间"><el-date-picker v-model="form.appointmentEndAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item v-if="!editing" label="预约状态"><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="备注"><el-input v-model="form.appointmentRemark" maxlength="1000" placeholder="有特殊要求时填写" /></el-form-item>
</el-form>
</details>
<template v-if="!editing && warehouse">
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
<template #toolbar-actions><el-button type="primary" :loading="candidateLoading" @click="loadCandidates(true)">查询可预约货物</el-button></template>
<template #notices><LoadErrorState v-if="candidateError" title="货物加载失败" :message="candidateError.message" @retry="loadCandidates()" /></template>
<template #pagination-left>已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物</template>
</DataTable>
</template>
<div v-else class="appointment-cargo">{{ editing.selectedCount }} 票货物 · 入仓码:{{ editing.items.map(item => item.inboundCode).join('、') || '—' }}</div>
<p v-else-if="!editing" class="appointment-hint">先选择预约仓库,即可勾选同仓货物,支持多个客户一起预约。</p>
<div v-else class="appointment-cargo">
<span>{{ editing.selectedCount }} 票货物</span>
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
</div>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.calendar-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.calendar-form :deep(.el-date-editor) { width: 100%; }
.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; }
.appointment-extra { margin-bottom: var(--admin-space-5); }
.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); }
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
</style>
@@ -0,0 +1,44 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import { listPlanningCargo } from './planningApi'
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
function render() {
return mount(AppointmentCargoPanel, { global: { stubs: {
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters"/><slot name="notices"/><button data-next @click="$emit(\'update:page\',2)">下一页</button></div>' },
QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
LoadErrorState: true, ApiErrorMeta: true, RowActions: true,
} } })
}
describe('appointment planning cargo query', () => {
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
it('uses one server-page request per entry, page change and reset', async () => {
const wrapper = render(); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
await wrapper.get('[data-search]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledTimes(3)
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
})
it('shows a source failure instead of an authoritative empty list', async () => {
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('目录不可用'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
})
it('discards a late previous page and uses the new server total', async () => {
let resolveOld!: (value: typeof empty) => void
vi.mocked(listPlanningCargo).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = render()
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ ...empty, page: 2, total: 21, pages: 2 })
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
resolveOld({ ...empty, total: 100, pages: 5 }); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(21)
expect(vi.mocked(listPlanningCargo).mock.calls[0]![1]!.aborted).toBe(true)
})
})
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listPlanningCargo, resolvePlanningSource, type ResolvedPlanningSource, type PlanningCargo } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const router = useRouter()
const access = useAccessPreviewStore()
const following = ref<ResolvedPlanningSource | null>(null)
const followupError = ref<ApiErrorDetails | null>(null)
let followupRequest = 0
const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 }]
const page = ref(1), size = ref(20), total = ref(0)
const rows = ref<PlanningCargo[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let request = 0
let controller: AbortController | undefined
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slot: 'actions', width: 150, fixed: 'left' },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ prop: 'customerName', label: '客户', minWidth: 150 },
{ prop: 'containerNo', label: '柜号', minWidth: 150 },
{ prop: 'originWarehouseName', label: '接仓点', minWidth: 140 },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '预报箱数', width: 100 },
{ prop: 'receivedPackages', label: '实收箱数', width: 100 },
{ prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 },
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 170 },
{ key: 'progress', label: '当前事项', slot: 'progress', minWidth: 170 },
]
async function load(reset = false) {
if (reset) page.value = 1
const current = ++request
controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listPlanningCargo({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
if (request !== current) return
rows.value = result.records; total.value = result.total
} catch (cause) {
if (request === current) { error.value = getApiErrorDetails(cause, '已开船货物加载失败'); rows.value = []; total.value = 0 }
} finally { if (request === current) loading.value = false }
}
function source(row: PlanningCargo) {
return router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderId }, query: { customerId: row.customerId } })
}
async function openFollowup(row: PlanningCargo) {
const request = ++followupRequest
followupError.value = null
try {
const result = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })
if (request === followupRequest) following.value = result
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
}
onMounted(() => load())
onBeforeUnmount(() => { ++request; ++followupRequest; 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="sourceKey" @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="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
<LoadErrorState v-if="error" title="已开船货物加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else class="cargo-note">来源预计时间供筹划核对,确认预约前请核对最新船期与货物备妥情况。</p>
</template>
<template #actions="{ row }"><RowActions :actions="[{ key: 'followup', label: '跟进' }, { key: 'source', label: '查看来源' }]" text-actions @action="key => key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" /></template>
<template #progress="{ row }">
<span v-if="row.detained">暂扣,待处理</span>
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
<span v-else-if="row.hasActiveAppointment">已有预约</span>
<span v-else>{{ row.hasInboundPlan ? '已建入仓计划,待安排' : '尚未建入仓计划' }}</span>
</template>
</DataTable>
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" @close="following = null" />
</template>
<style scoped>
.cargo-note { margin: var(--admin-space-3) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
</style>
@@ -0,0 +1,51 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { getFollowups, saveFollowup, type PlanningSource } from './planningApi'
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } }
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null }
function render(canWrite = true) {
return mount(AppointmentFollowupDialog, { props: { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
AppDialog: { props: ['confirmDisabled'], template: '<div><slot/><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<label><slot/></label>' },
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElSelect: true, ElOption: true, ElDatePicker: true,
DataTable: true, LoadErrorState: true, ApiErrorMeta: true,
} } })
}
describe('appointment followup recovery', () => {
beforeEach(() => {
vi.resetAllMocks(); vi.mocked(getFollowups).mockResolvedValue(history)
vi.mocked(saveFollowup).mockResolvedValue({ stage: 'DRAYAGE_CONTACT', version: 1, contactedParty: null, result: '已联系', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' })
})
it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(saveFollowup).toHaveBeenCalledWith('ORDER:1', 'A', expect.objectContaining({ stage: 'DRAYAGE_CONTACT', expectedVersion: 0, result: '已联系,等待回复', followupDate: null }))
const body = vi.mocked(saveFollowup).mock.calls[0]![2]
expect(body).not.toHaveProperty('appointmentNo'); expect(body).not.toHaveProperty('driverId'); expect(body).not.toHaveProperty('operatorId')
expect(getFollowups).toHaveBeenCalledTimes(2)
})
it('retries an unknown outcome with the exact same body and persisted command key', async () => {
vi.mocked(saveFollowup).mockRejectedValueOnce(new Error('network'))
const wrapper = render(); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const first = vi.mocked(saveFollowup).mock.calls[0]![2]
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
expect(first.idempotencyKey).toBeTruthy()
})
it('does not allow a write without write permission or a successfully loaded version', async () => {
const readonly = render(false); await flushPromises()
expect(readonly.get('[data-save]').attributes('disabled')).toBeDefined()
vi.mocked(getFollowups).mockRejectedValueOnce(new Error('unavailable'))
const failed = render(); await flushPromises()
expect(failed.get('[data-save]').attributes('disabled')).toBeDefined()
expect(saveFollowup).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getFollowups, saveFollowup, type PlanningSource, type AppointmentCoverage, type FollowupRecord, type FollowupStage, type SaveFollowup } from './planningApi'
const props = defineProps<{ source: PlanningSource; coverage: AppointmentCoverage; canWrite: boolean }>()
const emit = defineEmits<{ close: [] }>()
const stages: Record<FollowupStage, string> = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
const coverageNeeds: Record<string, string> = {
DESTINATION: '目的仓与原安排不一致,请核对。',
CARRIER_CONFIRMATION: '尚未确认承运,请先在原登记完成确认。',
REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先记录联系结果。',
CARGO_QUANTITY_REVIEW: '货量或重复关联需要核对。',
RECEIVING_APPOINTMENT: '尚未安排收货预约。',
UNCOVERED_CARGO: '部分货物尚未纳入收货预约。',
RECEIVING_CONFIRMATION: '尚未取得全部货物的收货确认。',
APPOINTMENT_TIMEZONE_UNVERIFIED: '原预约未记录时区,请核实收货地点当地时间。',
FBA_SHIPMENT_REFERENCES: 'FBA货件资料尚未齐全,请补充货件号或追踪号。',
SOURCE_CHANGED_REVIEW: '货源资料更新晚于预约,请核对是否需要改约。',
}
const form = reactive({ stage: (props.source.bookingNo ? 'FBA_RECEIVING' : 'DRAYAGE_CONTACT') as FollowupStage, contactedParty: '', result: '', nextAction: '', followupDate: '' })
const rows = ref<FollowupRecord[]>([])
const versions = ref<Record<FollowupStage, number>>({ DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 })
const page = ref(1), size = ref(10), total = ref(0)
const loading = ref(false), saving = ref(false), ready = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const pending = ref<SaveFollowup | null>(null)
let generation = 0
const columns: DataTableColumn[] = [
{ key: 'stage', label: '事项', slot: 'stage', width: 120 },
{ prop: 'contactedParty', label: '联系对象', minWidth: 130 },
{ prop: 'result', label: '联系结果', minWidth: 200 },
{ prop: 'nextAction', label: '下一步', minWidth: 150 },
{ prop: 'followupDate', label: '下次跟进日', width: 120 },
{ prop: 'operatorName', label: '跟进人', minWidth: 110 },
]
function rowKey(row: object) { const value = row as FollowupRecord; return `${value.stage}:${value.version}` }
async function load() {
const request = ++generation
loading.value = true; error.value = null; ready.value = false
try {
const result = await getFollowups(props.source.sourceKey, props.source.customerId, page.value, size.value)
if (request !== generation) return
rows.value = result.history.records; total.value = result.history.total; versions.value = result.versions; ready.value = true
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '跟进记录加载失败') }
finally { if (request === generation) loading.value = false }
}
async function save() {
if (!props.canWrite || !props.source.canFollowup || saving.value || !ready.value) return
if (!pending.value && (!form.result.trim() || (form.followupDate && !form.nextAction.trim()))) {
ElMessage.warning('请填写联系结果;设置下次跟进日时需填写下一步安排'); return
}
pending.value ||= { stage: form.stage, expectedVersion: versions.value[form.stage], idempotencyKey: crypto.randomUUID(),
contactedParty: form.contactedParty.trim() || undefined, result: form.result.trim(), nextAction: form.nextAction.trim() || undefined, followupDate: form.followupDate || null }
saving.value = true; saveError.value = null
try {
await saveFollowup(props.source.sourceKey, props.source.customerId, pending.value)
pending.value = null; form.result = ''; form.nextAction = ''; form.followupDate = ''; page.value = 1
ElMessage.success('跟进记录已保存'); await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
} finally { saving.value = false }
}
onMounted(load)
onBeforeUnmount(() => { ++generation })
</script>
<template>
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${source.inboundCode || source.bookingNo || '未关联入仓码'}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !source.canFollowup || !ready || loading" @confirm="save" @update:model-value="emit('close')">
<p>{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-for="reason in source.blockReasons" :key="reason">{{ reason }}</p>
<p v-if="coverage.changeReviewRequired">来源资料有变化,请核对原预约与运输安排。</p>
<p v-if="coverage.appointments.length">已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}</p>
<p v-else>尚未关联收货预约;联系记录保存后仍需登记对方正式确认。</p>
<ul v-if="coverage.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
<el-form v-if="canWrite && source.canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in stages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写待补资料或下次联系事项" /></el-form-item>
</el-form>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
</DataTable>
</AppDialog>
</template>
<style scoped>
.followup-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-6); }
.wide { grid-column: 1 / -1; }
.followup-form :deep(.el-select), .followup-form :deep(.el-date-editor) { width: 100%; }
</style>
@@ -0,0 +1,41 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
describe('appointment inbound code list', () => {
const codes = Array.from({ length: 20 }, (_, index) => `WT-LONG-INBOUND-CODE-${index + 1}`)
it('previews three codes, exposes the total and expands without losing full codes', async () => {
const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
expect(wrapper.findAll('li')).toHaveLength(3)
expect(wrapper.text()).toContain('20 个')
expect(wrapper.get('button').text()).toContain('还有 17 个')
expect(wrapper.get('li').attributes('title')).toBe(codes[0])
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
await wrapper.get('button').trigger('click')
expect(wrapper.findAll('li').map(row => row.text())).toEqual(codes)
expect(wrapper.get('ul').classes()).toContain('expanded')
expect(wrapper.get('ul').attributes('tabindex')).toBe('0')
expect(wrapper.get('button').attributes('aria-controls')).toBe(wrapper.get('ul').attributes('id'))
await wrapper.get('button').trigger('click')
expect(wrapper.findAll('li')).toHaveLength(3)
})
it('does not count missing or duplicate codes and shows no redundant toggle', () => {
const wrapper = mount(AppointmentInboundCodes, { props: { codes: [' A ', 'A', '', null, undefined, 'B'] } })
expect(wrapper.findAll('li').map(row => row.text())).toEqual(['A', 'B'])
expect(wrapper.text()).toContain('2 个')
expect(wrapper.find('button').exists()).toBe(false)
expect(mount(AppointmentInboundCodes, { props: { codes: [] } }).text()).toContain('—')
})
it('preserves expansion on equivalent data but resets when the appointment codes change', async () => {
const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
await wrapper.get('button').trigger('click')
await wrapper.setProps({ codes: [...codes] })
expect(wrapper.findAll('li')).toHaveLength(20)
await wrapper.setProps({ codes: codes.map(code => `${code}-NEW`) })
expect(wrapper.findAll('li')).toHaveLength(3)
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
})
})
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
const props = defineProps<{ codes: Array<string | null | undefined> }>()
const expanded = ref(false)
const listId = useId()
const codes = computed(() => [...new Set(props.codes.map(code => code?.trim()).filter((code): code is string => !!code))])
const visibleCodes = computed(() => expanded.value ? codes.value : codes.value.slice(0, 3))
watch(() => codes.value.join('\n'), () => { expanded.value = false })
</script>
<template>
<section class="inbound-codes" aria-label="关联入仓码">
<div class="codes-heading">
<span :title="`共 ${codes.length} 个入仓码`">入仓码 <span v-if="codes.length">({{ codes.length }} 个)</span></span>
<button v-if="codes.length > 3" type="button" :aria-expanded="expanded" :aria-controls="listId" @click="expanded = !expanded">
{{ expanded ? '收起' : `查看全部(还有 ${codes.length - 3} 个)` }}
</button>
</div>
<ul v-if="codes.length" :id="listId" class="codes-list" :class="{ expanded }" :tabindex="expanded ? 0 : undefined" aria-label="入仓码列表">
<li v-for="code in visibleCodes" :key="code" :title="code">{{ code }}</li>
</ul>
<span v-else class="codes-empty">—</span>
</section>
</template>
<style scoped>
.inbound-codes { min-width: 0; margin: var(--admin-space-5) 0; font-size: var(--admin-font-body); }
.codes-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.codes-heading button { border: 0; background: transparent; color: var(--el-color-primary); cursor: pointer; font: inherit; min-height: var(--admin-action-height); padding: 0; }
.codes-list { list-style: none; padding: 0; margin: var(--admin-space-1) 0 0; line-height: 1.8; }
.codes-list li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.codes-list.expanded { max-height: 216px; overflow-y: auto; overscroll-behavior: contain; }
.codes-list.expanded li { white-space: normal; overflow-wrap: anywhere; }
.codes-heading button:focus-visible, .codes-list:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.codes-empty { display: block; margin-top: var(--admin-space-1); }
</style>
@@ -7,13 +7,18 @@
| 页面/操作 | 原型承载与布局 | 实现约束 |
|---|---|---|
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
| 新增预约 | 居中双列表单,原型最大宽度760px | 保留居中双列表单;为跨客户货物选择扩至1080px,在同弹框下方分页选择真实入仓货物 |
| 新增预约 | 居中双列表单,原型最大宽度760px | 按2026-10-09用户简化要求,改用居中1080px三列表单;仓库从启用FBA主档远程搜索选择,选择后自动分页加载货物;主区保留仓库、送仓时间、预约编号、预计装车时间,结束时间/备注/新建默认预约中状态收至补充信息,在同弹框下方分页选择真实入仓货物 |
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 |
| 删除预约 | 二次确认 | 显式提示全部货物解除预约关联;独立删除权限,已关联有效排车拒绝删除 |
| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 |
| 多条安排 | 日历标签、右侧明细 | 月/周日格均最多显示3条,超出显示“还有N条”,悬停显示当天总数;日格固定高度,右侧全部展示并独立滚动 |
| 多入仓码 | 用户2026-10-09补充截图:单预约20票导致两列详情被长字符串撑高 | 右侧卡片与编辑弹框共用独立整行入仓码列表;去空去重后显示总数,默认3条,长码悬停可读全文;超过3条可展开/收起,展开后216px内独立滚动并可键盘聚焦,不拼成长段文字 |
| 在途货物及外接来源跟进 | 美国预约统一方案的提前联系需求 | 日历与已开船FBA列表切换;跟进在居中1080px弹框中承载,3列表单、联系结果及下一步整行,历史分页;BOOKING深链先由服务端核对,再展示原登记及真实预约缺项 |
| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选只在用户点击查询时分页读取,跨页选择保留,重新查询清空。
预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选在选仓后自动分页读取,跨页选择保留;换仓立即清空旧数据/选择并丢弃旧请求响应,清空仓库后不发送货物查询。后端必填的预计装车时间保留,不自动伪造;预约编号继续填写仓库提供的业务号。仓库请求失败可重试;仅展示前50个匹配仓,超出提示缩小关键词。
本轮视觉验收:待人工1920×1080截图确认。
视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
@@ -0,0 +1,14 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listPlanningCargo } 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 () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ sourceKey: 'ORDER:9007199254740993', orderId: '9007199254740993', estimatedArrivalAt: null }], page: '2', size: '20', total: '21', pages: '2' } })
const controller = new AbortController()
const result = await listPlanningCargo({ page: 2, size: 20, keyword: 'IN-A' }, controller.signal)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/cargo', { params: { page: 2, size: 20, keyword: 'IN-A' }, signal: controller.signal })
expect(result.records[0]!.orderId).toBe('9007199254740993')
expect(result.records[0]!.estimatedArrivalAt).toBeNull()
expect(result.total).toBe(21)
})
@@ -0,0 +1,71 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface PlanningCargo {
sourceKey: string
orderId: string
customerId: string
customerName: string | null
inboundCode: string | null
containerBookingId: string
containerNo: string | null
originWarehouseId: string
originWarehouseCode: string
originWarehouseName: string
destinationWarehouseCode: string
packages: number | null
receivedPackages: number | null
shippedPackages: number | null
sourcePalletCount: number | null
volumeCbm: number | null
estimatedArrivalAt: string | null
estimatedWarehouseAt: string | null
orderVersion: number
standardNode: string
shippingVersion: string | null
shippingObservedAt: string | null
shippingProvider: string | null
hasInboundPlan: boolean
hasActiveAppointment: boolean
detained: boolean
mixedDestinations: boolean
}
export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<PlanningCargoPage>, ApiEnvelope<PlanningCargoPage>>(
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
)
const page = unwrapApiResponse(response)
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
}
export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record<FollowupStage, number> }
export interface SaveFollowup { stage: FollowupStage; expectedVersion: number; idempotencyKey: string; contactedParty?: string; result: string; nextAction?: string; followupDate: string | null }
export async function getFollowups(sourceKey: string, customerId: string, page: number, size: number) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<FollowupHistory>, ApiEnvelope<FollowupHistory>>(
`/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(sourceKey)}/follow-ups`, { params: { customerId, page, size } },
))
return { ...result, history: { ...result.history, total: Number(result.history.total) }, versions: Object.fromEntries(Object.entries(result.versions).map(([stage, version]) => [stage, Number(version)])) as Record<FollowupStage, number> }
}
export async function saveFollowup(sourceKey: string, customerId: string, body: SaveFollowup) {
return unwrapApiResponse(await http.post<ApiEnvelope<FollowupRecord>, ApiEnvelope<FollowupRecord>>(
`/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(sourceKey)}/follow-ups`, body, { params: { customerId } },
))
}
export interface PlanningSource {
sourceKey: string; customerId: string; orderId: string | null; inboundCode: string | null; bookingNo: string | null;
containerNo: string | null; originWarehouseId: string; originWarehouseName: string; destinationKey: string;
destinationWarehouseCode: string; packages: number | null; sourceVersion: string | null; canFollowup: boolean; blockReasons: string[];
}
export interface AppointmentCoverage {
status: string; missingFields: string[]; changeReviewRequired: boolean;
appointments: Array<{ appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }>;
}
export interface ResolvedPlanningSource { source: PlanningSource; coverage: AppointmentCoverage }
export async function resolvePlanningSource(query: { sourceKey: string; customerId: string; destinationKey?: string }) {
return unwrapApiResponse(await http.get<ApiEnvelope<ResolvedPlanningSource>, ApiEnvelope<ResolvedPlanningSource>>(
'/platform/v1/last-leg/appointment-planning/sources/resolve', { params: query },
))
}