feat(admin): streamline appointments and fleet intake
This commit is contained in:
@@ -23,7 +23,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
|
||||
@@ -135,7 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
{ key: 'fleet-capacity-offers', title: '拼车接货', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
))
|
||||
}
|
||||
@@ -37,3 +37,5 @@
|
||||
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
|
||||
|
||||
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
|
||||
|
||||
- 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。
|
||||
|
||||
@@ -130,3 +130,17 @@
|
||||
| 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 |
|
||||
|
||||
| 出车运营应收/实收读取 | `新版.html:8941–8987` 出车记录列表;车辆规划第4.1节及本轮明确确认的Finance权威口径 | 沿原运营列表应收/实收列,点击读取;金额来源在620px只读浮层展示,不新增菜单或财务写表单 | 单车次按需读取,未分配明确显示待分配,缺权限不请求;真实1920×1080、100%视觉及请求量待总控验收 |
|
||||
|
||||
## 拼车接货操作简化开发矩阵(2026-10-09)
|
||||
|
||||
依据:用户要求按最新版《拼车数据入口与操作简化评估草案》第七、八节开发,沿既有居中1080px表单和独立发布详情,不引入外部原型。
|
||||
|
||||
| 页面 | 操作 | 承载与尺寸 | 调整及验收边界 |
|
||||
|---|---|---|---|
|
||||
| 拼车接货 | 可拼车次、我的登记、待办 | integrated列表,操作列左置 | 直接登记,销售隐藏容量计算过程,服务端返回处理进度;待1920×1080人工截图 |
|
||||
| 客户货物登记 | 新增、补充资料、只读跟进 | 1080px居中三列 | 首屏客户/目的地/已知货量;确认前必填折叠但显示缺项;始终选填独立;单站自动带入 |
|
||||
| 客户货物登记 | 确认承运、取消 | 既有居中确认框 | 展示对象及保位/商业订单影响,保存成功不冒充确认成功 |
|
||||
| 客户货物登记 | 安排来货、核量、排入本车 | 1080px居中,复用履约表单 | 原登记连续处理,按服务端资格;关联业务按权限跳转 |
|
||||
| 车次准备 | 选择合格排车、新建或加入车次、连续发布 | 1080px居中,复用车次与发布表单 | 来源带入、版本和失败续办,未授权的预约事实不由此生成 |
|
||||
|
||||
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeBooking, normalizeIncoming, normalizeBookingResult } from './tripBookings'
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CapacityOffer, CapacityInquiry, CapacityOfferDetail, CapacityLoad, OfferPage, OfferQuery, InquiryQuery, OfferWrite, OfferCommand, InquiryWrite, InquiryCommand, OfferResult, InquiryResult, OfferCustomerOption } from '../capacityOfferTypes'
|
||||
import { normalizeFleetQuotePage, normalizeFleetQuoteSource, type WireQuotePage } from './fleetQuoteCandidates'
|
||||
@@ -5,7 +6,7 @@ import type { TripBookingQuoteCriteria } from './tripBookings'
|
||||
const base = '/platform/v1/last-leg/fleet/capacity-offers'
|
||||
const path = (value: string) => `${base}/${encodeURIComponent(value)}`
|
||||
const inquiryPath = (value: string) => `${base}/inquiries/${encodeURIComponent(value)}`
|
||||
const number = (value: number) => { const n = Number(value); if (value == null || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
|
||||
const number = (value: number | string) => { const n = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
|
||||
const nullable = (value: number | null) => value == null ? null : number(value)
|
||||
const load = (value: CapacityLoad | null) => value == null ? null : { palletSpaces: number(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) }
|
||||
export function normalizeOffer(value: CapacityOffer): CapacityOffer { return { ...value, recordVersion: number(value.recordVersion), tripRecordVersion: number(value.tripRecordVersion), destinations: value.destinations.map(d => ({ ...d, deliveryIndex: number(d.deliveryIndex), remaining: load(d.remaining) })), segments: value.segments.map(s => ({ ...s, fromIndex: number(s.fromIndex), toIndex: number(s.toIndex), planned: load(s.planned), reserved: load(s.reserved), remaining: load(s.remaining) })) } }
|
||||
@@ -26,3 +27,13 @@ export async function saveCapacityInquiry(offerNo: string, bookingNo: string | n
|
||||
export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/${action}`, body)) }
|
||||
export const listOfferCustomers = (keyword?: string) => get<OfferCustomerOption[]>(`${base}/customer-options`, { keyword })
|
||||
export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get<WireQuotePage>(`${path(offerNo)}/quote-candidates`, query)) }
|
||||
|
||||
export async function getInquiryFulfillment(bookingNo: string) {
|
||||
const value = await get<{ booking: import('./tripBookings').WireBooking; preview: { tripRecordVersion: number | string; recordVersion: number | string; incoming: import('./tripBookings').WireIncoming | null }; plannedDepartureAt: string }>(`${inquiryPath(bookingNo)}/fulfillment`)
|
||||
return { booking: normalizeBooking(value.booking), plannedDepartureAt: value.plannedDepartureAt, preview: { ...value.preview, tripRecordVersion: number(value.preview.tripRecordVersion), recordVersion: number(value.preview.recordVersion), incoming: value.preview.incoming ? normalizeIncoming(value.preview.incoming) : null } }
|
||||
}
|
||||
export async function commandInquiryFulfillment(bookingNo: string, action: 'incoming' | 'receipt-confirmation' | 'arrange-dispatch', body: import('../bookingTypes').TripIncomingStart | import('../bookingTypes').TripIncomingAccept | import('../bookingTypes').TripBookingBind | import('../bookingTypes').TripBookingCommand) {
|
||||
return normalizeBookingResult(await post<import('./tripBookings').WireResult>(`${inquiryPath(bookingNo)}/${action}`, body))
|
||||
}
|
||||
|
||||
export async function saveAndConfirmInquiry(bookingNo: string, body: InquiryWrite) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/save-and-confirm`, body)) }
|
||||
|
||||
@@ -6,16 +6,16 @@ const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings
|
||||
type N = number | string
|
||||
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
|
||||
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
|
||||
type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
|
||||
export type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
|
||||
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
|
||||
type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
|
||||
export type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
|
||||
const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result }
|
||||
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
|
||||
const nullable = (value: N | null) => value == null ? null : number(value)
|
||||
const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
export const normalizeIncoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
|
||||
const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
|
||||
export const normalizeBookingResult = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
|
||||
export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
|
||||
export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) {
|
||||
return normalizeFleetQuotePage(unwrapApiResponse(await http.get<ApiEnvelope<WireQuotePage>, ApiEnvelope<WireQuotePage>>(`${path(tripNo)}/quote-candidates`, { params: criteria })))
|
||||
@@ -28,13 +28,13 @@ export async function listTripBookings(tripNo: string): Promise<TripBookingList>
|
||||
return { tripRecordVersion: integer(value.tripRecordVersion), records: value.records.map(normalizeBooking) }
|
||||
}
|
||||
export async function createTripBooking(tripNo: string, body: TripBookingCreate): Promise<TripBookingResult> {
|
||||
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(path(tripNo), body)))
|
||||
return normalizeBookingResult(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(path(tripNo), body)))
|
||||
}
|
||||
export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise<TripBookingResult> {
|
||||
return result(unwrapApiResponse(await http.put<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
|
||||
return normalizeBookingResult(unwrapApiResponse(await http.put<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
|
||||
}
|
||||
async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise<TripBookingResult> {
|
||||
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body)))
|
||||
return normalizeBookingResult(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body)))
|
||||
}
|
||||
export const confirmTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'confirm', body)
|
||||
export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'cancel', body)
|
||||
@@ -42,10 +42,10 @@ export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripB
|
||||
const bookingPath = (tripNo: string, bookingNo: string) => `${path(tripNo)}/${encodeURIComponent(bookingNo)}`
|
||||
export async function getTripIncoming(tripNo: string, bookingNo: string): Promise<TripIncomingPreview> {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>, ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>>(`${bookingPath(tripNo, bookingNo)}/incoming`))
|
||||
return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : incoming(value.incoming) }
|
||||
return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : normalizeIncoming(value.incoming) }
|
||||
}
|
||||
async function incomingCommand(tripNo: string, bookingNo: string, action: string, body: TripIncomingStart | TripIncomingAccept | TripBookingBind): Promise<TripBookingResult> {
|
||||
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${bookingPath(tripNo, bookingNo)}/${action}`, body)))
|
||||
return normalizeBookingResult(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${bookingPath(tripNo, bookingNo)}/${action}`, body)))
|
||||
}
|
||||
export const startTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingStart) => incomingCommand(tripNo, bookingNo, 'incoming', body)
|
||||
export const acceptTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingAccept) => incomingCommand(tripNo, bookingNo, 'receipt-confirmation', body)
|
||||
|
||||
@@ -111,3 +111,13 @@ export async function listTripHistory(tripNo: string, currentPage = 1, size = 20
|
||||
completion: row.snapshot.completion == null ? null : { ...row.snapshot.completion, outcomes: row.snapshot.completion.outcomes.map(outcome) },
|
||||
} }))
|
||||
}
|
||||
|
||||
export interface DispatchCandidate { dispatchNo: string; customerId: string; warehouseId: string; packageCount: number; route: string | null }
|
||||
export async function listTripDispatchCandidates(tripNo: string, keyword?: string) {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<WirePage<DispatchCandidate>>, ApiEnvelope<WirePage<DispatchCandidate>>>(`${path(tripNo)}/dispatch-candidates`, { params: { keyword, page: 1, size: 100 } }))
|
||||
return { ...value, total: integer(value.total), records: value.records.map(row => ({ ...row, packageCount: integer(row.packageCount) })) }
|
||||
}
|
||||
export interface TripPreparation { tripNo: string | null; recordVersion: number | null; plan: TripDraft | null; vehicleId: string | null; driverId: string | null; dispatchNos: string[]; idempotencyKey: string }
|
||||
export async function prepareTrip(body: TripPreparation): Promise<TripDetail> {
|
||||
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ export interface CapacityOffer {
|
||||
destinations: OfferDestination[]; segments: OfferSegment[]; capacityKnown: boolean; canCreateInquiry: boolean; canConfirm: boolean; blockReason: string | null
|
||||
actions: { canEdit: boolean; canPublish: boolean; canPause: boolean; canResume: boolean; canClose: boolean }
|
||||
}
|
||||
export interface InquiryProgress { stage: string; label: string; reservation: string; owner: string; primaryAction: string; message: string; missingFields: string[] }
|
||||
export interface AppointmentCoverage { status: string; appointments: { appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }[]; missingFields: string[]; sourceVersion: string | null; changeReviewRequired: boolean }
|
||||
export interface CapacityInquiry {
|
||||
appointmentCoverage: AppointmentCoverage | null
|
||||
progress: InquiryProgress
|
||||
bookingNo: string; offerNo: string; tripNo: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'
|
||||
recordVersion: number; offerRecordVersion: number; tripRecordVersion: number; salesOwnerUserId: string
|
||||
customerId: string; customerCode: string; customerName: string; businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
|
||||
@@ -23,7 +27,7 @@ export interface CapacityInquiry {
|
||||
}
|
||||
export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage<CapacityInquiry> }
|
||||
export interface OfferQuery { scope?: 'AVAILABLE' | 'ALL'; page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
|
||||
export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' }
|
||||
export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' | 'TODO' }
|
||||
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
|
||||
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
|
||||
export interface InquiryWrite {
|
||||
@@ -36,6 +40,9 @@ export interface OfferResult { offer: CapacityOffer; replayed: boolean }
|
||||
export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
|
||||
export interface OfferCustomerOption { id: string; code: string; name: string }
|
||||
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
|
||||
export const inquiryStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' }
|
||||
export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
|
||||
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
|
||||
|
||||
export const appointmentCoverageLabels: Record<string, string> = { SOURCE_NOT_READY: '待确认承运后筹划', SOURCE_NOT_FOUND: '来源待核对', SOURCE_NOT_MATERIALIZED: '待形成真实货件资料', DESTINATION_MISMATCH: '目的地变化待核对', REVIEW_REQUIRED: '货量待复核', UNASSIGNED: '尚未关联收货预约', PARTIAL: '部分货物未覆盖', PENDING: '等待收货方确认', CONFIRMED: '预约记录已确认' }
|
||||
export const appointmentMissingLabels: Record<string, string> = { CARRIER_CONFIRMATION: '承运确认', REAL_CARGO_MATERIALS: '真实货件资料', DESTINATION: '目的地核对', CARGO_QUANTITY_REVIEW: '货量复核', RECEIVING_APPOINTMENT: '收货预约', UNCOVERED_CARGO: '未覆盖货物的预约', RECEIVING_CONFIRMATION: '收货方确认', APPOINTMENT_TIMEZONE_UNVERIFIED: '预约时间所属地区尚未核实', SOURCE_CHANGED_REVIEW: '货源变化后重新核对预约' }
|
||||
|
||||
@@ -40,8 +40,8 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
|
||||
const { wrapper, pinia } = mountDialog(kind)
|
||||
if (kind === 'settings') await wrapper.setProps({ modelValue: true })
|
||||
await flushPromises()
|
||||
if (kind === 'settings') await wrapper.get('details summary').trigger('click')
|
||||
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
|
||||
await wrapper.get('details summary').trigger('click')
|
||||
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="填写货物名称(确认承运前必填)"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
await input.setValue('UI-未保存检查'); await flushPromises()
|
||||
const assertPreserved = () => {
|
||||
@@ -81,8 +81,9 @@ it('blocks browser back with the real router, cached detail and native dialog, w
|
||||
await router.push('/pool'); await router.isReady()
|
||||
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
|
||||
await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
|
||||
const input = wrapper.get<HTMLInputElement>('input[placeholder="选填"]')
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('details summary').trigger('click')
|
||||
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
|
||||
await input.setValue('UI-修复后保留输入'); await flushPromises()
|
||||
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises(); await continueEditing()
|
||||
confirms.mockClear()
|
||||
@@ -99,8 +100,8 @@ it('blocks browser back with the real router, cached detail and native dialog, w
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get<HTMLInputElement>('input[placeholder="选填"]').setValue('另一次修改'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
|
||||
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
|
||||
await continueEditing(); await navigation
|
||||
|
||||
@@ -31,7 +31,7 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
|
||||
const source: FleetQuoteSourceSnapshot = { quoteNo: 'FQ-PAGE-2', recordVersion: '9007199254740993', priceItem: scenario.priceBasis === 'ORDER' ? 'FULL_DELIVERY' : 'LOOSE_DELIVERY', customerId: '19', customerName: '报价测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 35, currency: 'CNY', priceBasis: scenario.priceBasis, quotedUnitPrice: 25.5, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
|
||||
vi.mocked(listOfferQuotes).mockResolvedValueOnce({ records: Array.from({ length: 20 }, (_, index) => ({ ...source, quoteNo: `FQ-PAGE-1-${index + 1}` })), total: 21, page: 1, size: 20, pages: 2 }).mockResolvedValueOnce({ records: [source], total: 21, page: 2, size: 20, pages: 2 })
|
||||
vi.mocked(saveCapacityInquiry).mockImplementation(async (_offerNo, _bookingNo, body) => {
|
||||
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
|
||||
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
|
||||
return { offer, inquiry, replayed: false }
|
||||
})
|
||||
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
|
||||
@@ -54,7 +54,7 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
|
||||
await price.get('input').setValue('21.75'); await flushPromises()
|
||||
expect(price.props('modelValue')).toBe(21.75)
|
||||
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
|
||||
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存意向')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存登记')!.trigger('click'); await flushPromises()
|
||||
expect(saveCapacityInquiry).toHaveBeenCalledExactlyOnceWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', offerRecordVersion: 4, tripRecordVersion: 7, recordVersion: null, quoteRef: reference, idempotencyKey: expect.any(String), cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, unitPrice: 21.75 }) }))
|
||||
expect(listOfferQuotes).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
|
||||
@@ -6,21 +6,22 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
|
||||
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
|
||||
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels } from '../capacityOfferTypes'
|
||||
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
|
||||
import type { TripBookingCargo } from '../bookingTypes'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean }>()
|
||||
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: [] }>()
|
||||
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 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)
|
||||
const customerError = ref<ApiErrorDetails | null>(null), customerSearched = ref(false)
|
||||
const quoteRows = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null), quoteSearched = ref(false), quotePage = ref(1), quotePages = ref(0)
|
||||
const pending = ref<{ target: string; body: InquiryWrite } | null>(null)
|
||||
const pending = ref<{ target: string; confirm: boolean; body: InquiryWrite } | null>(null)
|
||||
const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo || '')
|
||||
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
|
||||
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
|
||||
@@ -40,6 +41,7 @@ watch(() => [open.value, target.value], async () => {
|
||||
else {
|
||||
const row = props.inquiry
|
||||
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
|
||||
if (!row && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
|
||||
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
|
||||
}
|
||||
await nextTick(); guard.capture()
|
||||
@@ -69,30 +71,35 @@ async function save() {
|
||||
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
pending.value ||= { target: target.value, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
|
||||
const result = await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
|
||||
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success('意向已保存,尚未占用运力') }
|
||||
pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
|
||||
const result = pending.value.confirm && props.inquiry ? await saveAndConfirmInquiry(props.inquiry.bookingNo, pending.value.body) : await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
|
||||
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success(result.inquiry.status === 'CONFIRMED' ? '已保留货位并关联本土订单,尚未安排来货' : '登记已保存,调度可见,尚未保留货位') }
|
||||
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :confirm-loading="busy" @confirm="save">
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
|
||||
<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-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
|
||||
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
|
||||
<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" 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>
|
||||
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="选填" /></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>
|
||||
<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>
|
||||
<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-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>
|
||||
<el-form-item label="计价方式"><el-select v-model="form.cargo.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
|
||||
@@ -100,14 +107,31 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
<el-form-item label="币种"><el-select v-model="form.cargo.currency" filterable allow-create><el-option v-for="currency in ['USD', 'CNY', 'CAD', 'EUR', 'GBP']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="form.cargo.contactName" placeholder="确认承运前补充" /></el-form-item>
|
||||
<el-form-item label="联系方式"><el-input v-model="form.cargo.contactPhone" placeholder="确认承运前补充" /></el-form-item>
|
||||
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item>
|
||||
</div></details>
|
||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>其他说明(选填)</summary>
|
||||
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item></details>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="customerError" title="客户候选读取失败" :message="customerError.message" @retry="searchCustomers()"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
|
||||
<section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
|
||||
<details><summary>参考报价(可选)</summary><section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
|
||||
<template v-if="canQuote && writable"><el-button :disabled="lock || !quoteReady" :loading="quoteLoading" @click="queryQuotes()">查询报价库</el-button><el-button v-if="form.quoteRef" :disabled="lock" @click="form.quoteRef = null; quoteSource = null">改为手工询价</el-button>
|
||||
<el-select v-if="quoteRows.length" :model-value="form.quoteRef ? sourceKey(form.quoteRef) : ''" :disabled="lock" placeholder="选择报价,可另行议价" @update:model-value="selectQuote"><el-option v-for="quote in quoteRows" :key="sourceKey(quote)" :value="sourceKey(quote)" :label="`${quote.quoteNo} · ${quote.quotedUnitPrice} ${quote.currency}`" /></el-select>
|
||||
<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>
|
||||
</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>
|
||||
<p v-for="appointment in inquiry.appointmentCoverage.appointments" :key="appointment.appointmentNo">预约:{{ appointment.appointmentNo }} · 外部确认号:{{ appointment.confirmationNo || '尚未确认' }}</p>
|
||||
<p v-if="inquiry.appointmentCoverage.changeReviewRequired">货源或数量发生变化,请负责预约的人员重新核对覆盖。</p>
|
||||
</section>
|
||||
<p v-if="inquiry">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
|
||||
</AppDialog>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getInquiryFulfillment } from '../api/capacityOffers'
|
||||
import type { CapacityInquiry } from '../capacityOfferTypes'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
const dialog = ref<InstanceType<typeof TripBookingFulfillmentDialog> | null>(null)
|
||||
const activeMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ inquiry: CapacityInquiry | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const loaded = ref<Awaited<ReturnType<typeof getInquiryFulfillment>> | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), loading = ref(false)
|
||||
let generation = 0
|
||||
async function load() {
|
||||
if (!props.inquiry || !open.value) return
|
||||
const current = ++generation; loading.value = true; error.value = null
|
||||
try { const value = await getInquiryFulfillment(props.inquiry.bookingNo); if (current === generation) loaded.value = value }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本票履约进度读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [open.value, props.inquiry?.bookingNo, props.mode], () => { generation++; if (!open.value || dialog.value?.hasPending) return; activeMode.value = props.mode; loaded.value = null; void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" v-if="!loaded" v-model="open" title="读取本票处理进度" width="1080px" :show-confirm="false">
|
||||
<p v-if="loading">正在读取本票真实履约资料…</p>
|
||||
<LoadErrorState v-if="error" title="读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<TripBookingFulfillmentDialog v-else ref="dialog" v-model="open" pool :booking="loaded.booking" :initial-preview="loaded.preview" :mode="activeMode"
|
||||
:arrival-at="inquiry?.estimatedArrivalAt" :instructions="inquiry?.cargo.remark"
|
||||
:trip="{ tripNo: loaded.booking.tripNo, recordVersion: loaded.preview.tripRecordVersion, plannedDepartureAt: loaded.plannedDepartureAt }"
|
||||
@saved="emit('saved')" />
|
||||
</template>
|
||||
@@ -10,16 +10,20 @@ const access = useAccessPreviewStore()
|
||||
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
const manage = access.can('platform:fleet:capacity-offer:manage')
|
||||
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
|
||||
return [{ key: 'view', label: '详情', priority: 10 },
|
||||
{ key: 'edit', label: '编辑', visible: row.actions.canEdit && write('edit'), disabled: busy },
|
||||
{ key: 'confirm', label: '确认承运', visible: row.actions.canConfirm && manage, disabled: busy },
|
||||
return [{ key: 'view', label: '查看进度', priority: 10 },
|
||||
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
|
||||
{ key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
|
||||
{ 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'), disabled: busy },
|
||||
{ key: 'fulfillment', label: '收货进度', visible: !!row.incomingNo, disabled: busy },
|
||||
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
|
||||
{ key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
|
||||
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210 }, { key: 'customerName', label: '客户', minWidth: 155 },
|
||||
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
|
||||
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
|
||||
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
|
||||
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
|
||||
]
|
||||
@@ -29,7 +33,7 @@ const columns: DataTableColumn[] = [
|
||||
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
|
||||
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||
<template #status="{ row }">{{ inquiryStatusLabels[(row as CapacityInquiry).status] }}<small v-if="row.actions.blockReason" class="inquiry-reason">{{ row.actions.blockReason }}</small></template>
|
||||
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
|
||||
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
@@ -14,7 +15,7 @@ const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, i
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||||
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
|
||||
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
|
||||
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
|
||||
@@ -43,3 +44,15 @@ it('retries the same incoming registration after an uncertain result without cre
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an uncertain receipt command retryable after closing and reopening without reloading or changing its hash', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
|
||||
const wrapper=mount('receipt'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const original=vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(false)
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
|
||||
expect(api.getTripIncoming).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -7,22 +7,26 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTripIncoming, startTripIncoming, acceptTripIncoming, bindTripBookingDispatch } from '../api/tripBookings'
|
||||
import { getInquiryFulfillment, commandInquiryFulfillment } from '../api/capacityOffers'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: FleetTrip; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
|
||||
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
|
||||
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
|
||||
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
|
||||
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
|
||||
defineExpose({ hasPending: computed(() => !!pending.value) })
|
||||
let generation = 0
|
||||
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
|
||||
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '关联外接排车单', view: '外接来货详情' }
|
||||
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '确认排入本车', view: '外接来货详情' }
|
||||
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
|
||||
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
|
||||
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
|
||||
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
|
||||
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])])
|
||||
@@ -30,29 +34,36 @@ async function load() {
|
||||
if (!props.booking) return
|
||||
const current = generation; loading.value = true; error.value = null
|
||||
try {
|
||||
const value = await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
|
||||
const value = props.pool ? (await getInquiryFulfillment(props.booking.bookingNo)).preview : await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
|
||||
if (current !== generation) return
|
||||
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
|
||||
await nextTick(); guard.capture()
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [open.value, target.value], () => {
|
||||
generation++; busy.value = false; error.value = null; preview.value = null
|
||||
if (!open.value || !props.booking) return
|
||||
if (pending.value) return
|
||||
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
|
||||
Object.assign(form, { estimatedArrivalAt: new Date(new Date(props.trip.plannedDepartureAt).getTime() - 7200000).toISOString(), deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
if (pending.value?.target !== target.value) pending.value = null
|
||||
if (props.mode === 'receipt' || props.mode === 'view') void load()
|
||||
Object.assign(form, { estimatedArrivalAt: props.arrivalAt || '', deliveryInstructions: props.instructions || props.booking.cargo.remark || '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
if (props.initialPreview && (props.mode === 'receipt' || props.mode === 'view')) {
|
||||
preview.value = props.initialPreview; Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
for (const pallet of props.initialPreview.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
|
||||
void nextTick(() => guard.capture())
|
||||
} else if (props.mode === 'receipt' || props.mode === 'view') void load()
|
||||
else void nextTick(() => guard.capture())
|
||||
}, { immediate: true })
|
||||
async function save() {
|
||||
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
|
||||
const current = generation, booking = props.booking, mode = props.mode
|
||||
if (!pending.value) {
|
||||
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
|
||||
if (mode === 'incoming' && !form.estimatedArrivalAt) { ElMessage.warning('请确认预计到仓时间'); return }
|
||||
if (mode === 'incoming') pending.value = { target: target.value, body: { ...versions, ...form, estimatedArrivalAt: form.estimatedArrivalAt || null } }
|
||||
else if (mode === 'bind') {
|
||||
if (!dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
|
||||
if (!props.pool && !dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
|
||||
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
|
||||
} else {
|
||||
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
|
||||
@@ -63,32 +74,32 @@ async function save() {
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const body = pending.value.body
|
||||
const value = mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
|
||||
const value = props.pool ? await commandInquiryFulfillment(booking.bookingNo, mode === 'incoming' ? 'incoming' : mode === 'receipt' ? 'receipt-confirmation' : 'arrange-dispatch', mode === 'bind' ? { tripRecordVersion: (body as TripBookingBind).tripRecordVersion, recordVersion: (body as TripBookingBind).recordVersion, idempotencyKey: (body as TripBookingBind).idempotencyKey } : body) : mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
|
||||
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
|
||||
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
|
||||
if (current === generation) { emit('saved', value); pending.value = null; open.value = false; ElMessage.success('外接履约记录已更新') }
|
||||
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success('外接履约记录已更新') }
|
||||
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '关联排车单' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (!pending && mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="booking" v-loading="loading">
|
||||
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
|
||||
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p><p v-if="pending" role="status">本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。</p>
|
||||
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
|
||||
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
|
||||
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
|
||||
</el-form>
|
||||
<template v-if="mode === 'receipt' || mode === 'view'">
|
||||
<p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
|
||||
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
|
||||
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
|
||||
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
|
||||
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
|
||||
</template>
|
||||
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
|
||||
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p v-if="pool">将本票全部实收货物排入原车次。系统复用合格排车单,没有时生成并关联;来源冲突或货量不完整时停止。成功后仍需核对容量并完成审核,不代表已装车或发车。</p><p v-else>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item v-if="!pool" label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { listTripDispatchCandidates, type DispatchCandidate } from '../api/trips'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
const value = defineModel<string>({ default: '' })
|
||||
const props = defineProps<{ tripNo: string; disabled?: boolean }>()
|
||||
const rows = ref<DispatchCandidate[]>([]), loading = ref(false), error = ref(''), total = ref(0)
|
||||
let sequence = 0, timer: ReturnType<typeof setTimeout> | undefined
|
||||
function search(keyword = '') {
|
||||
clearTimeout(timer); const current = ++sequence
|
||||
timer = setTimeout(async () => {
|
||||
loading.value = true; error.value = ''
|
||||
try { const result = await listTripDispatchCandidates(props.tripNo, keyword.trim() || undefined); if (current === sequence) { rows.value = result.records; total.value = result.total } }
|
||||
catch (cause) { if (current === sequence) { rows.value = []; error.value = getApiErrorDetails(cause, '待运货物加载失败').message } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
watch(() => props.tripNo, () => { sequence++; clearTimeout(timer); rows.value = []; value.value = ''; loading.value = false })
|
||||
onBeforeUnmount(() => { sequence++; clearTimeout(timer) })
|
||||
</script>
|
||||
<template><div><el-select v-model="value" aria-label="选择本仓待运排车单" filterable remote :remote-method="search" :loading="loading" :disabled="disabled" placeholder="按排车单号或目的地搜索" @focus="search()"><el-option v-for="row in rows" :key="row.dispatchNo" :label="`${row.dispatchNo} · ${row.route || '路线待核对'} · ${row.packageCount}箱`" :value="row.dispatchNo" /></el-select><p v-if="error" role="alert">{{ error }} <el-button link @click="search()">重试</el-button></p><small v-if="total > rows.length">候选较多,请输入排车单号缩小范围。</small></div></template>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import TripPreparationDialog from './TripPreparationDialog.vue'
|
||||
import { listTrips, prepareTrip } from '../api/trips'
|
||||
import type { TripDetail, FleetTrip } from '../tripTypes'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('../api/trips', () => ({ listTrips: vi.fn(), prepareTrip: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
const trip = { tripNo: 'TR-READY', recordVersion: 4, warehouseId: '9007199254740993', stops: [{ name: '起运仓' }, { name: 'ACY2' }] } as FleetTrip
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listTrips).mockResolvedValue({ records: [trip], page: 1, size: 100, total: 1, pages: 1 }) })
|
||||
function render() {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '<div><slot/></div>' }
|
||||
return shallowMount(TripPreparationDialog, { props: { modelValue: true, warehouseId: trip.warehouseId, warehouseName: '本仓', dispatchNos: ['DS-ONE', 'DS-TWO'] }, global: { plugins: [createPinia()], stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['showConfirm'], emits: ['confirm'], template: '<div><slot/></div>' },
|
||||
ElForm: field, ElFormItem: field, ElSelect: { ...field, name: 'ElSelect' }, ElOption: true,
|
||||
ElRadioGroup: { ...field, name: 'ElRadioGroup' }, ElRadio: true, ElDatePicker: field,
|
||||
ElButton: { template: '<button><slot/></button>' }, CapacityOfferSettingsDialog: true,
|
||||
} } })
|
||||
}
|
||||
it('loads same-warehouse trips on demand and retries one immutable atomic preparation', async () => {
|
||||
const wrapper=render(); await flushPromises()
|
||||
expect(listTrips).not.toHaveBeenCalled()
|
||||
const select=wrapper.findComponent({ name: 'ElSelect' })
|
||||
select.vm.$emit('focus'); await flushPromises()
|
||||
expect(listTrips).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '9007199254740993', status: 'PLANNED' }))
|
||||
select.vm.$emit('update:modelValue', trip.tripNo); await flushPromises()
|
||||
vi.mocked(prepareTrip).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ trip, dispatches: [], driverQualification: null, loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } } as TripDetail)
|
||||
const dialog=wrapper.findComponent({ name: 'AppDialog' })
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const original=vi.mocked(prepareTrip).mock.calls[0]![0]
|
||||
expect(original).toMatchObject({ tripNo: 'TR-READY', recordVersion: 4, dispatchNos: ['DS-ONE','DS-TWO'], plan: null, vehicleId: null, driverId: null })
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(prepareTrip).toHaveBeenNthCalledWith(2, original)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('核对容量并发布余位')
|
||||
expect(dialog.props('showConfirm')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listTrips, prepareTrip, type TripPreparation } from '../api/trips'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import type { FleetTrip, TripDetail } from '../tripTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { DriverDetail } from '../driverTypes'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ warehouseId: string; warehouseName: string; dispatchNos: string[] }>()
|
||||
const emit = defineEmits<{ saved: [value: TripDetail] }>()
|
||||
const access = useAccessPreviewStore(), router = useRouter()
|
||||
const form = reactive({ mode: 'existing', tripNo: '', vehicleId: '', driverId: '', departure: '', end: '' })
|
||||
const trips = ref<FleetTrip[]>([]), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||||
const busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const submittedSummary = ref<{ warehouseName: string; dispatchNos: string[] } | null>(null)
|
||||
const pending = ref<TripPreparation | null>(null), result = ref<TripDetail | null>(null), publishOpen = ref(false)
|
||||
const canCreate = computed(() => access.can('platform:fleet:trip:create') && access.can('platform:fleet:trip:assign'))
|
||||
const guard = useFleetDraftGuard('trip-preparation', open, form, busy, computed(() => !!pending.value))
|
||||
let generation = 0, searchSequence = 0
|
||||
watch(open, async visible => {
|
||||
generation++; searchSequence++
|
||||
if (!visible || pending.value) return
|
||||
Object.assign(form, { mode: 'existing', tripNo: '', vehicleId: '', driverId: '', departure: '', end: '' })
|
||||
result.value = null; trips.value = []; vehicles.value = []; drivers.value = []; error.value = null
|
||||
await nextTick(); guard.capture()
|
||||
})
|
||||
async function search(kind: 'trip' | 'vehicle' | 'driver', keyword = '') {
|
||||
const current = ++searchSequence; loading.value = true; error.value = null
|
||||
try {
|
||||
if (kind === 'trip') { const value = await listTrips({ page: 1, size: 100, warehouseId: props.warehouseId, status: 'PLANNED', keyword: keyword || undefined }); if (current === searchSequence) trips.value = value.records }
|
||||
if (kind === 'vehicle') { const value = await listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE', keyword: keyword || undefined }); if (current === searchSequence) vehicles.value = value.records }
|
||||
if (kind === 'driver') { const value = await listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: form.end || undefined, keyword: keyword || undefined }); if (current === searchSequence) drivers.value = value.records }
|
||||
} catch (cause) { if (current === searchSequence) error.value = getApiErrorDetails(cause, '候选读取失败') }
|
||||
finally { if (current === searchSequence) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || !access.can('platform:fleet:trip:edit')) return
|
||||
if (!pending.value) {
|
||||
const creating = form.mode === 'new', selected = trips.value.find(t => t.tripNo === form.tripNo)
|
||||
if (!props.warehouseId || !props.dispatchNos.length) { ElMessage.warning('请选择同仓待运排车单'); return }
|
||||
if (creating ? !canCreate.value || !form.vehicleId || !form.driverId || !form.departure || !form.end : !selected) { ElMessage.warning('请完整选择车次或新车次资源和时间'); return }
|
||||
submittedSummary.value = { warehouseName: props.warehouseName, dispatchNos: [...props.dispatchNos] }
|
||||
const key = crypto.randomUUID()
|
||||
pending.value = { tripNo: creating ? null : selected!.tripNo, recordVersion: creating ? null : selected!.recordVersion,
|
||||
vehicleId: creating ? form.vehicleId : null, driverId: creating ? form.driverId : null, dispatchNos: [...props.dispatchNos], idempotencyKey: key,
|
||||
plan: creating ? { warehouseId: props.warehouseId, plannedDepartureAt: form.departure, expectedDriverEndAt: form.end, stops: [{ name: '起运仓' }, { name: '待确定卸货点' }], idempotencyKey: key } : null }
|
||||
}
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const value = await prepareTrip(pending.value)
|
||||
if (current === generation) { result.value = value; pending.value = null; guard.capture(); emit('saved', value); ElMessage.success('已安排车次,请核对容量后发布余位') }
|
||||
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '安排车次未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++; searchSequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="安排车次" width="1080px" :before-close="guard.beforeClose" :show-confirm="!result" :confirm-text="pending ? '重试原请求' : '确认安排'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="安排未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p>{{ pending ? submittedSummary?.warehouseName : warehouseName }} · 已选 {{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.length }} 张排车单:{{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.join('、') }}</p>
|
||||
<p v-if="pending">提交结果尚未确定,请保留原请求重试,不另建车次。</p>
|
||||
<template v-if="result"><p>已安排到 {{ result.trip.tripNo }} · {{ result.trip.stops.map(s => s.name).join(' → ') }}</p><p>多目的地请先在车次中确认行驶顺序,再核对容量发布余位。安排成功不代表已审核或发车。</p><el-button v-if="access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: result.trip.tripNo } })">查看车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:list')" type="primary" @click="publishOpen = true">核对容量并发布余位</el-button></template>
|
||||
<el-form v-else label-position="top" class="density-form preparation-form" :disabled="busy || !!pending">
|
||||
<el-form-item label="安排方式"><el-radio-group v-model="form.mode"><el-radio value="existing">加入已有车次</el-radio><el-radio v-if="canCreate" value="new">新建车次</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="form.mode === 'existing'" label="本仓计划中车次" required><el-select v-model="form.tripNo" filterable remote :remote-method="(k: string) => search('trip', k)" :loading="loading" placeholder="按车次、车牌或司机搜索" @focus="search('trip')"><el-option v-for="t in trips" :key="t.tripNo" :value="t.tripNo" :label="`${t.tripNo} · ${t.vehiclePlate || '待分配车辆'} · ${t.stops.map(s => s.name).join(' → ')}`" /></el-select></el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
|
||||
<el-form-item label="车辆" required><el-select v-model="form.vehicleId" placeholder="按车牌搜索" filterable remote :remote-method="(k: string) => search('vehicle', k)" :loading="loading" @focus="search('vehicle')"><el-option v-for="v in vehicles" :key="v.id" :value="v.id" :label="`${v.plateNumber} · ${v.maxPalletSpaces ?? '待核对'}托位`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机" required><el-select v-model="form.driverId" placeholder="按姓名搜索" filterable remote :remote-method="(k: string) => search('driver', k)" :loading="loading" @focus="search('driver')"><el-option v-for="d in drivers" :key="d.id" :value="d.id" :label="d.details.name" /></el-select></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<CapacityOfferSettingsDialog v-if="result" v-model="publishOpen" :trip="result.trip" @trip-updated="result.trip = $event" />
|
||||
</template>
|
||||
<style scoped>.preparation-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.preparation-form :deep(.el-select),.preparation-form :deep(.el-date-editor){width:100%}</style>
|
||||
@@ -3,3 +3,7 @@ export { overseasMasterDataPermissions } from './permissions'
|
||||
export type * from './types'
|
||||
export { default as TransportRouteDialog } from './components/TransportRouteDialog.vue'
|
||||
export { routeLocation, type RouteLocation } from './routeLocations'
|
||||
|
||||
export { default as TripPreparationDialog } from './components/TripPreparationDialog.vue'
|
||||
export { default as TripDispatchSelector } from './components/TripDispatchSelector.vue'
|
||||
export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type DispatchCandidate } from './api/trips'
|
||||
|
||||
@@ -10,6 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
|
||||
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import { getCapacityOffer, getCapacityInquiry, listCapacityInquiries } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, offerTime, loadText, type CapacityOfferDetail, type CapacityInquiry, type InquiryResult } from '../capacityOfferTypes'
|
||||
@@ -17,6 +18,16 @@ import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<CapacityOfferDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), inquiryError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), readonly = ref(false)
|
||||
const confirmMode = ref(false)
|
||||
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
function openFulfillment(key: string, row: CapacityInquiry) {
|
||||
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
|
||||
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
|
||||
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
|
||||
editorOpen.value = false
|
||||
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
|
||||
return true
|
||||
}
|
||||
let sequence = 0, inquirySequence = 0, resetting = false
|
||||
const hasCreatePermission = computed(() => (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')))
|
||||
const canCreate = computed(() => detail.value?.offer.canCreateInquiry && hasCreatePermission.value)
|
||||
@@ -49,8 +60,8 @@ function accept(value: InquiryResult) {
|
||||
for (const row of rows) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
|
||||
}
|
||||
const commands = useCapacityInquiryCommands('capacity-offer-command', accept)
|
||||
async function action(key: string, row: CapacityInquiry) { if (key === 'confirm' || key === 'cancel') await commands.run(row, key); else { editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true } }
|
||||
function create() { if (!canCreate.value) return; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
async function action(key: string, row: CapacityInquiry) { if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } }
|
||||
function create() { if (!canCreate.value) return; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
watch(() => [route.name, route.params.offerNo], ([name, offerNo]) => {
|
||||
if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return
|
||||
detail.value = null; editorOpen.value = false; void load()
|
||||
@@ -59,17 +70,18 @@ watch([page, size], loadInquiries)
|
||||
onBeforeUnmount(() => { sequence++; inquirySequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车需求池</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记意向</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车接货</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>交货截止</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记意向:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
|
||||
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记意向不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
|
||||
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
||||
<h2 class="capacity-section-title">分段运力</h2><DataTable :data="detail.offer.segments" :columns="columns" :stripe="false" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable>
|
||||
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的意向' }}</h2>
|
||||
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
|
||||
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
|
||||
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
|
||||
</template></div>
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="load" />
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
|
||||
</DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
|
||||
|
||||
@@ -14,9 +14,9 @@ const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, rout
|
||||
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), saveAndConfirmInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
|
||||
export const offer: CapacityOffer = { offerNo: 'CO-1', tripNo: 'TR-1', status: 'OPEN', recordVersion: 2, tripRecordVersion: 5, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-10T12:00:00Z', cutoffAt: '2026-10-10T10:00:00Z', contactName: '调度', contactPhone: '测试联系信息', remark: null, capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待调度核对', destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
|
||||
export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
|
||||
export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
|
||||
const pageOf = <T,>(records: T[], total = records.length) => ({ records, total, page: 1, size: 20, pages: Math.ceil(total / 20) })
|
||||
const wrappers: VueWrapper[] = []
|
||||
const layout = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
@@ -44,7 +44,7 @@ it('loads only the active tab and sends exactly one query after resetting from a
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
|
||||
await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled()
|
||||
const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' }))
|
||||
@@ -52,7 +52,7 @@ it('loads only the active tab and sends exactly one query after resetting from a
|
||||
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
|
||||
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
|
||||
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
resolve(pageOf([offer], 900)); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1)
|
||||
vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
@@ -62,7 +62,7 @@ it('ignores an older page response after changing tabs and retains an explicit e
|
||||
it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => {
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
await button(wrapper, '登记意向').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '登记客户货物').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true)
|
||||
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -100,7 +100,7 @@ it('retains an uncertain request and route protection after closing and reopenin
|
||||
})
|
||||
it('keeps sales from confirming even if a stale row advertises the action', async () => {
|
||||
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -110,27 +110,31 @@ it('loads publishing settings only when explicitly opened, not with the trip pag
|
||||
expect(api.listCapacityOffers).not.toHaveBeenCalled(); await (wrapper as VueWrapper<Record<string, unknown>>).setProps({ modelValue: true }); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ scope: 'ALL', tripNo: 'TR-1', page: 1, size: 1 })
|
||||
})
|
||||
it('retries an uncertain dispatch confirmation with the same command and no second confirmation box', async () => {
|
||||
it('retries an uncertain combined save and confirmation with the original payload', async () => {
|
||||
state.manage = true
|
||||
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
|
||||
vi.mocked(api.commandCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: true })
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
|
||||
const first = vi.mocked(api.commandCapacityInquiry).mock.calls[0]!
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(api.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledOnce()
|
||||
vi.mocked(api.saveAndConfirmInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: true })
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: false, confirmMode: true }); await flushPromises()
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' })
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.saveAndConfirmInquiry).mock.calls[0]!
|
||||
expect(first[0]).toBe(row.bookingNo)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveAndConfirmInquiry).toHaveBeenNthCalledWith(2, ...first)
|
||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
|
||||
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(button(wrapper, '登记意向').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('无法登记意向:交货截止已过')
|
||||
expect(button(wrapper, '登记客户货物').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('无法登记客户货物:交货截止已过')
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
|
||||
state.write = false
|
||||
const readonly = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记意向')).toBe(false)
|
||||
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
|
||||
})
|
||||
it('defaults administrators to available offers and requires an explicit history filter', async () => {
|
||||
state.manage = true
|
||||
@@ -142,3 +146,18 @@ it('defaults administrators to available offers and requires an explicit history
|
||||
query.vm.$emit('update:modelValue', {}); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
||||
})
|
||||
|
||||
it('automatically selects a single destination and preserves unknown quantities when only the customer is known', async () => {
|
||||
vi.mocked(api.saveCapacityInquiry).mockResolvedValue({ offer, inquiry, replayed: false })
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', '19'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveCapacityInquiry).toHaveBeenCalledWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null }) }))
|
||||
})
|
||||
it('requires an explicit choice for multiple destinations', async () => {
|
||||
const many = { ...offer, destinations: [...offer.destinations, { ...offer.destinations[0]!, destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2 }] }
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer: many, inquiry: null, readonly: false }); await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', '19'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
|
||||
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
|
||||
@@ -20,6 +21,16 @@ const tab = ref<'offers' | 'inquiries'>('offers'), filters = ref<QueryModel>({})
|
||||
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)
|
||||
const confirmMode = ref(false)
|
||||
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
function openFulfillment(key: string, row: CapacityInquiry) {
|
||||
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
|
||||
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
|
||||
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
|
||||
editorOpen.value = false
|
||||
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
|
||||
return true
|
||||
}
|
||||
let sequence = 0, editorSequence = 0
|
||||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
||||
@@ -30,15 +41,22 @@ const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
||||
] : [
|
||||
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
|
||||
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的意向', options: [{ value: 'MINE', label: '我的意向' }, { value: 'ALL', label: '全部意向' }] } as QueryField] : []),
|
||||
...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的登记', options: [{ value: 'MINE', label: '我的登记' }, { value: 'ALL', label: '全部登记' }, { value: 'TODO', label: '待我处理' }] } as QueryField] : []),
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'offerNo', label: '发布号 / 车次号', minWidth: 220, slotName: 'identity' },
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 200, fixed: 'left', slotName: 'actions' }, { key: 'offerNo', label: '发布号 / 车次号', minWidth: 220, slotName: 'identity' },
|
||||
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
|
||||
{ key: 'plannedDepartureAt', label: '发车 / 交货截止', minWidth: 195, slotName: 'times' },
|
||||
{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false },
|
||||
{ key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
|
||||
...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
|
||||
{ key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
|
||||
]
|
||||
])
|
||||
const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create'))
|
||||
async function create(row: CapacityOffer) {
|
||||
if (!canCreate.value || !row.canCreateInquiry) return
|
||||
const current = ++editorSequence; error.value = null
|
||||
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
|
||||
}
|
||||
function switchTab(value: 'offers' | 'inquiries') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
@@ -48,10 +66,10 @@ async function load() {
|
||||
const query: OfferQuery = { scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'AVAILABLE', page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
|
||||
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
|
||||
} else {
|
||||
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
|
||||
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'TODO' ? 'TODO' : manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
|
||||
const value = await listCapacityInquiries(query); if (current === sequence) { inquiries.value = value.records; total.value = value.total }
|
||||
}
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车需求池读取失败') }
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车接货读取失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
|
||||
@@ -63,13 +81,16 @@ function accept(value: InquiryResult) {
|
||||
}
|
||||
const commands = useCapacityInquiryCommands('capacity-pool-command', accept)
|
||||
async function inquiryAction(key: string, row: CapacityInquiry) {
|
||||
if (key === 'confirm' || key === 'cancel') { await commands.run(row, key); return }
|
||||
if (openFulfillment(key, row)) return
|
||||
if (key === 'cancel') { await commands.run(row, key); return }
|
||||
if (key === 'confirm' && !manage.value) return
|
||||
confirmMode.value = key === 'confirm'
|
||||
const current = ++editorSequence; error.value = null
|
||||
if (key === 'edit') {
|
||||
if (key === 'edit' || key === 'confirm') {
|
||||
try { const detail = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = detail.offer }
|
||||
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '意向编辑信息读取失败'); return }
|
||||
} else selectedOffer.value = null
|
||||
editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true
|
||||
editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true
|
||||
}
|
||||
async function recheckEditor() {
|
||||
if (!editing.value) return
|
||||
@@ -82,33 +103,34 @@ async function recheckEditor() {
|
||||
}
|
||||
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车需求池">
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车接货">
|
||||
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||
<template #toolbar-start><h1>拼车需求池</h1></template>
|
||||
<template #toolbar-start><h1>拼车接货</h1></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可接运力</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的意向</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可接运力查询" :loading="loading" @search="search" />
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车接货页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
||||
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
|
||||
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
|
||||
<template #times="{ row }"><div>{{ offerTime(row.plannedDepartureAt) }}</div><div>截止 {{ offerTime(row.cutoffAt) }}</div></template>
|
||||
<template #times="{ row }"><div>发车 {{ offerTime(row.plannedDepartureAt) }}</div><div>最晚送仓 {{ offerTime(row.cutoffAt) }}</div></template>
|
||||
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
|
||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可确认承运' : '暂不可确认' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
||||
</DataTable>
|
||||
<CapacityInquiryTable v-else integrated v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||||
<template #toolbar-start><h1>{{ applied.scope === 'ALL' ? '全部意向' : '我的意向' }}</h1></template>
|
||||
<template #toolbar-start><h1>{{ applied.scope === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}</h1></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可接运力</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的意向</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车接货页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
|
||||
</CapacityInquiryTable>
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="recheckEditor" />
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||||
</ListPageLayout></AdminPage></template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import TripDispatchSelector from '../components/TripDispatchSelector.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -194,8 +195,8 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<template v-if="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><el-input v-model="dispatchNo" placeholder="输入待关联的排车单号" :disabled="!!busy" @keyup.enter="bind" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<p>先分配车辆和司机,关联排车单并匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
||||
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
||||
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ export const overseasMasterDataRoutes = [
|
||||
path: 'overseas-master-data/capacity-offers',
|
||||
name: 'overseas-master-data-capacity-offers',
|
||||
component: () => import('./pages/CapacityOffersView.vue'),
|
||||
meta: { title: '拼车需求池', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
meta: { title: '拼车接货', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/capacity-offers/:offerNo',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus, { ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -69,7 +70,7 @@ describe('DispatchesView', () => {
|
||||
it('queries stages on the server and discards an older response after switching', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof listDispatches>>) => void
|
||||
vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await wrapper.get('[role="tablist"]').findAll('button')[2]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING', page: 1, status: undefined }))
|
||||
@@ -83,7 +84,7 @@ describe('DispatchesView', () => {
|
||||
})
|
||||
|
||||
it('shows the shared route map only in the after-loading vehicle section without another query', async () => {
|
||||
const wrapper = mount(DispatchesView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('查看路线')
|
||||
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[3]!.trigger('click')
|
||||
@@ -106,7 +107,7 @@ describe('DispatchesView', () => {
|
||||
result.records[0]!.workflow = { tripNo: 'TR-A', dispatchReview: { status: 'REJECTED', reason: '请核对货量', reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
|
||||
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
|
||||
canPermission.mockImplementation(code => code === 'platform:fleet:trip:list')
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
const sections = wrapper.get('[aria-label="排车业务分区"]')
|
||||
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息'])
|
||||
@@ -133,7 +134,7 @@ describe('DispatchesView', () => {
|
||||
})
|
||||
|
||||
it.each([0, 2, 3, 4].flatMap(stageIndex => [1, 2].map(sectionIndex => [stageIndex, sectionIndex])))('leaving a review section for stage %s resets section %s to vehicle information', async (stageIndex, sectionIndex) => {
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
const stages = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
|
||||
const sections = wrapper.get('[aria-label="排车业务分区"]')
|
||||
@@ -163,7 +164,7 @@ describe('DispatchesView', () => {
|
||||
[['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true],
|
||||
])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => {
|
||||
canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action))
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
const actions = wrapper.get('.el-table__body-wrapper .density-actions')
|
||||
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
|
||||
@@ -172,7 +173,7 @@ describe('DispatchesView', () => {
|
||||
|
||||
it('keeps the integrated header and filters usable through failure and retry', async () => {
|
||||
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('网络错误'))
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
const card = wrapper.get('.data-table--integrated')
|
||||
expect(card.get('.data-table__toolbar h1').text()).toBe('排车列表')
|
||||
@@ -188,7 +189,7 @@ describe('DispatchesView', () => {
|
||||
})
|
||||
|
||||
it('loads immediately and sends only supported page filters', async () => {
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listDispatches).toHaveBeenCalledWith({ page: 1, size: 10, dispatchNo: undefined, vehiclePlate: undefined, status: undefined, fbaAppointmentNo: undefined })
|
||||
|
||||
@@ -221,7 +222,7 @@ describe('DispatchesView', () => {
|
||||
records: [{ id: '190000000000000099', dispatchNo: '190000000000000099' }],
|
||||
page: 1, size: 20, total: 1, pages: 1,
|
||||
})
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.html()).toContain('190000000000000099')
|
||||
})
|
||||
@@ -230,7 +231,7 @@ describe('DispatchesView', () => {
|
||||
const pdfWindow = { location: { href: '' }, close: vi.fn() }
|
||||
vi.spyOn(window, 'open').mockReturnValue(pdfWindow as unknown as Window)
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:dispatch-bol'), revokeObjectURL: vi.fn() })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(getDispatchBolPdf).not.toHaveBeenCalled()
|
||||
|
||||
@@ -251,7 +252,7 @@ describe('DispatchesView', () => {
|
||||
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [
|
||||
{ bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 },
|
||||
] })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(getDispatchExternalFreight).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
|
||||
@@ -276,7 +277,7 @@ describe('DispatchesView', () => {
|
||||
cargo: [{ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: '401', sourceRegion: 'FIRSTLEG', sourceInboundId: '11', inboundCode: 'IN-11', packages: 20, dispatchInboundCode: 'IN-11' }],
|
||||
feeSources: [{ financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '101', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original-fee-hash', status: 'AVAILABLE' }],
|
||||
})
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(getDispatchFeeCoverage).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
|
||||
@@ -315,7 +316,7 @@ describe('DispatchesView', () => {
|
||||
expect(source).not.toContain('departDispatch')
|
||||
|
||||
canPermission.mockReturnValue(false)
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('转派送')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { TripPreparationDialog, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -35,6 +35,16 @@ function openRoute(row: DispatchListItem) {
|
||||
}
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<DispatchListItem[]>([])
|
||||
const selectedDispatches = ref<DispatchListItem[]>([]), prepareOpen = ref(false), prepareWarehouse = ref(''), prepareWarehouseName = ref(''), prepareNumbers = ref<string[]>([])
|
||||
const canPrepare = computed(() => access.can('platform:fleet:trip:edit') && access.can('platform:fleet:trip:list'))
|
||||
function beginPreparation() {
|
||||
const selected = selectedDispatches.value
|
||||
if (!selected.length || selected.some(row => row.statusCode !== 'PENDING_DEPARTURE' || row.workflow)) { ElMessage.warning('请选择尚未关联车次的待发运排车单'); return }
|
||||
const warehouses = new Set(selected.map(row => dispatchSnapshots.get(row.dispatchNo)?.overseasWarehouseId))
|
||||
if (warehouses.size !== 1 || ![...warehouses][0]) { ElMessage.warning('请选择同一起运仓的排车单'); return }
|
||||
prepareWarehouse.value = String([...warehouses][0]); prepareWarehouseName.value = selected[0]!.overseasWarehouse
|
||||
prepareNumbers.value = selected.map(row => row.dispatchNo); prepareOpen.value = true
|
||||
}
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
@@ -277,6 +287,7 @@ const visibleColumns = computed(() => {
|
||||
const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status']
|
||||
return keys.map(key => columns.find(column => column.key === key)!)
|
||||
})
|
||||
const preparationColumns = computed<DataTableColumn[]>(() => canPrepare.value ? [visibleColumns.value[0]!, { key: 'selection', type: 'selection', width: 45, selectable: (row: DispatchListItem) => row.statusCode === 'PENDING_DEPARTURE' && !row.workflow }, ...visibleColumns.value.slice(1)] : visibleColumns.value)
|
||||
const tablePreferenceKey = computed(() => section.value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches-${section.value}`)
|
||||
|
||||
function text(value: unknown) {
|
||||
@@ -652,9 +663,9 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="visibleColumns" :loading="loading" :total="total" :preference-key="tablePreferenceKey" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>排车列表</h1></template>
|
||||
<OperationErrorToast :details="mutationError" /><TripPreparationDialog v-model="prepareOpen" :warehouse-id="prepareWarehouse" :warehouse-name="prepareWarehouseName" :dispatch-nos="prepareNumbers" @saved="load()" />
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="preparationColumns" @selection-change="selectedDispatches = $event" :loading="loading" :total="total" :preference-key="tablePreferenceKey" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>排车列表</h1></template><template #toolbar-end><el-button v-if="canPrepare" type="primary" :disabled="!selectedDispatches.length" @click="beginPreparation">安排车次</el-button></template>
|
||||
<template #filters><div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车执行阶段">
|
||||
<button v-for="item in stages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }}</button>
|
||||
|
||||
Reference in New Issue
Block a user