feat(appointments): simplify planning and preserve source warehouse intent
This commit is contained in:
@@ -46,7 +46,7 @@ describe('appointment calendar interactions', () => {
|
||||
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
|
||||
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '删除')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click')
|
||||
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
})
|
||||
|
||||
@@ -109,7 +109,7 @@ defineExpose({ selectedDate, mode })
|
||||
<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>
|
||||
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,9 @@ 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'
|
||||
import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } 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() }))
|
||||
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() }))
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => ({ push
|
||||
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), deleteAppointment: vi.fn() }))
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'source-read' || routing.canSourceRead }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', () => ({
|
||||
vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/modules/overseas-warehouse')>()),
|
||||
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
|
||||
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
|
||||
overseasWarehousePermissions: { inboundOrderAppointment: 'write', lastMileOrderList: 'source-read' },
|
||||
@@ -22,8 +22,8 @@ vi.mock('@/modules/overseas-warehouse', () => ({
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
|
||||
const candidates = [
|
||||
{ lineId: '9007199254740993', customerId: 'A', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
|
||||
{ lineId: '9007199254740994', customerId: 'B', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
|
||||
{ lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
|
||||
{ lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
|
||||
]
|
||||
function render() {
|
||||
return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
|
||||
@@ -48,9 +48,10 @@ describe('appointment calendar data integration', () => {
|
||||
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(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0})
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||||
vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
|
||||
vi.mocked(createFbaAppointment).mockResolvedValue({ 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: [] })
|
||||
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
|
||||
})
|
||||
|
||||
it('creates one shared appointment for two customers without sending a fabricated header customer', async () => {
|
||||
@@ -64,13 +65,12 @@ describe('appointment calendar data integration', () => {
|
||||
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')
|
||||
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(createFbaAppointment).toHaveBeenCalledTimes(1)
|
||||
expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({
|
||||
appointmentNo: 'APT-AB', lineIds: ['9007199254740993', '9007199254740994'],
|
||||
originWarehouseId: '100', fbaWarehouseCode: 'ONT8', lineIds: ['9007199254740993', '9007199254740994'], idempotencyKey: expect.any(String), details: expect.objectContaining({plannedDate: '2026-10-09', appointmentStatus: 'PENDING', appointmentAt: null, confirmationNo: null}),
|
||||
}))
|
||||
expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId')
|
||||
expect(listInboundOrders).toHaveBeenCalledTimes(1)
|
||||
@@ -88,6 +88,27 @@ describe('appointment calendar data integration', () => {
|
||||
await flushPromises()
|
||||
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
|
||||
})
|
||||
it('keeps a pending unknown save retry on the same immutable command', async () => {
|
||||
vi.mocked(createFbaAppointment).mockRejectedValueOnce(new Error('response lost'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
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('[data-save]').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(createFbaAppointment).mock.calls[0]![0]
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first)
|
||||
expect(first.details.appointmentAt).toBeNull()
|
||||
expect(first.details.confirmationNo).toBeNull()
|
||||
expect(first.idempotencyKey).toBeTruthy()
|
||||
})
|
||||
it('keeps undated arrangements outside calendar time slots', async () => {
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValueOnce({ records: [{ id:'1', version:0, appointmentNo:'AP-UNSCHEDULED', originWarehouseId:'100', appointmentStatus:'PENDING', selectedCount:2, fbaWarehouseCode:'ONT8', items:[] }], total:1 })
|
||||
const wrapper=render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('未定时安排(1)')
|
||||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
|
||||
expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt')
|
||||
})
|
||||
|
||||
it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => {
|
||||
const wrapper = render()
|
||||
@@ -96,8 +117,7 @@ describe('appointment calendar data integration', () => {
|
||||
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')
|
||||
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('')
|
||||
await wrapper.get('[data-save]').trigger('click')
|
||||
expect(createFbaAppointment).not.toHaveBeenCalled()
|
||||
@@ -148,7 +168,7 @@ describe('appointment calendar data integration', () => {
|
||||
|
||||
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: [] }])
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }])
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
|
||||
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -15,7 +15,8 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import {
|
||||
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
|
||||
overseasWarehousePermissions as permission,
|
||||
type FbaAppointmentDto, type BatchFbaAppointmentPayload, type InboundOrderSummaryDto,
|
||||
FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError,
|
||||
type FbaAppointmentDto, type FbaAppointmentDetailsPayload, type InboundOrderSummaryDto,
|
||||
} from '@/modules/overseas-warehouse'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
@@ -65,8 +66,18 @@ let calendarController: AbortController | undefined
|
||||
let calendarRequest = 0
|
||||
let candidateRequest = 0
|
||||
let detailRequest = 0
|
||||
const form = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'PENDING' as BatchFbaAppointmentPayload['appointmentStatus'], appointmentRemark: '' })
|
||||
const statusLabels: Record<string, string> = { PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' }
|
||||
const form = reactive(createAppointmentForm())
|
||||
const pendingSave = ref<{ appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: FbaAppointmentDetailsPayload; idempotencyKey: string } | null>(null)
|
||||
const unscheduled = ref<FbaAppointmentDto[]>([]), unscheduledTotal = ref(0), unscheduledPage = ref(1), unscheduledSize = ref(10)
|
||||
const unscheduledError = ref<ApiErrorDetails | null>(null)
|
||||
const unscheduledColumns: DataTableColumn[] = [{key:'actions',label:'操作',slotName:'actions',width:100,fixed:'left'},{prop:'appointmentNo',label:'系统预约单号',minWidth:200},{prop:'fbaWarehouseCode',label:'目的仓',width:100},{prop:'selectedCount',label:'票数',width:80}]
|
||||
let unscheduledRequest = 0
|
||||
async function loadUnscheduled() {
|
||||
const current = ++unscheduledRequest; unscheduledError.value = null
|
||||
try { const result = await listUnscheduledAppointments(unscheduledPage.value, unscheduledSize.value); if(current === unscheduledRequest) {unscheduled.value = result.records; unscheduledTotal.value = result.total} }
|
||||
catch(cause) {if(current === unscheduledRequest) unscheduledError.value = getApiErrorDetails(cause, '未定时安排加载失败')}
|
||||
}
|
||||
const statusLabels: Record<string, string> = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' }
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
||||
@@ -89,15 +100,17 @@ const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival =>
|
||||
{ label: '货物去向', value: arrival.destinationWarehouseCode },
|
||||
],
|
||||
})), ...appointments.value.map(appointment => ({
|
||||
key: appointment.appointmentNo, type: 'delivery' as const, date: appointment.appointmentAt.slice(0, 10),
|
||||
time: appointment.appointmentAt.slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} 预约送仓`,
|
||||
key: appointment.appointmentNo, type: 'delivery' as const, date: (appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '').slice(0, 10),
|
||||
time: (appointment.appointmentAt || appointment.plannedAt || '').slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} ${appointment.appointmentStatus === 'SUCCESS' ? '已确认送仓' : '计划送仓'}`,
|
||||
fields: [
|
||||
{ label: '预约仓库', value: appointment.fbaWarehouseCode },
|
||||
{ label: '预约编号', value: appointment.appointmentNo },
|
||||
{ label: '系统单号', value: appointment.appointmentNo },
|
||||
{ label: '外部约号', value: appointment.confirmationNo },
|
||||
{ label: '地点时区', value: appointment.timezone || '待核实' },
|
||||
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
|
||||
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
|
||||
{ label: '预约备注', value: appointment.appointmentRemark },
|
||||
],
|
||||
].filter(field => field.value != null && field.value !== ''),
|
||||
inboundCodes: appointment.items.map(item => item.inboundCode),
|
||||
canEdit: access.can(permission.inboundOrderAppointment),
|
||||
canDelete: access.can('platform:appointment:delete'),
|
||||
@@ -144,16 +157,17 @@ function openCreate(date: string) {
|
||||
candidateTotal.value = 0
|
||||
candidateError.value = null
|
||||
dialogError.value = null
|
||||
Object.assign(form, { appointmentEndAt: '', appointmentNo: '', appointmentAt: `${date}T09:00:00`, expectedLoadingAt: '', appointmentStatus: 'PENDING', appointmentRemark: '' })
|
||||
Object.assign(form, createAppointmentForm(), { plannedDate: date }); pendingSave.value = null
|
||||
dialogOpen.value = true
|
||||
detailReady.value = true
|
||||
void searchWarehouses('')
|
||||
}
|
||||
|
||||
async function openEdit(event: CalendarEvent) {
|
||||
async function openEdit(event: Pick<CalendarEvent, 'key'>) {
|
||||
const request = ++detailRequest
|
||||
dialogError.value = null
|
||||
editing.value = appointments.value.find(item => item.appointmentNo === event.key) || null
|
||||
editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null
|
||||
pendingSave.value = null
|
||||
if (!editing.value) return
|
||||
dialogOpen.value = true
|
||||
detailLoading.value = true
|
||||
@@ -164,9 +178,7 @@ async function openEdit(event: CalendarEvent) {
|
||||
editing.value = detail
|
||||
detailReady.value = true
|
||||
warehouse.value = detail.fbaWarehouseCode
|
||||
Object.assign(form, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt,
|
||||
expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus,
|
||||
appointmentRemark: detail.appointmentRemark || '' })
|
||||
Object.assign(form, createAppointmentForm(detail))
|
||||
} catch (error) {
|
||||
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
|
||||
} finally { if (request === detailRequest) detailLoading.value = false }
|
||||
@@ -222,13 +234,13 @@ async function loadCandidates() {
|
||||
|
||||
async function save() {
|
||||
if (saving.value || detailLoading.value || !detailReady.value) return
|
||||
if (!form.appointmentNo.trim() || !form.appointmentAt || !form.expectedLoadingAt) {
|
||||
ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return
|
||||
}
|
||||
const validation = appointmentFormError(form)
|
||||
if (!pendingSave.value && validation) { ElMessage.warning(validation); 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 (selected.value.some(item => !item.overseasWarehouseId) || new Set(selected.value.map(item => item.overseasWarehouseId)).size !== 1) { ElMessage.warning('请选择同一接仓点的货物'); return }
|
||||
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
|
||||
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
|
||||
}
|
||||
@@ -236,14 +248,15 @@ async function save() {
|
||||
saving.value = true
|
||||
dialogError.value = null
|
||||
try {
|
||||
const common = { appointmentEndAt: form.appointmentEndAt || null, appointmentAt: form.appointmentAt, expectedLoadingAt: form.expectedLoadingAt,
|
||||
appointmentStatus: form.appointmentStatus, appointmentRemark: form.appointmentRemark.trim() || undefined }
|
||||
if (editing.value) await updateFbaAppointment(editing.value.appointmentNo, { ...common, version: editing.value.version })
|
||||
else await createFbaAppointment({ ...common, appointmentNo: form.appointmentNo.trim(), lineIds: selected.value.map(item => item.lineId) })
|
||||
pendingSave.value ||= {appointmentNo: editing.value?.appointmentNo || null, version: editing.value?.version || 0, originWarehouseId: String(selected.value[0]?.overseasWarehouseId || ''), fbaWarehouseCode: searchedWarehouse.value, lineIds: selected.value.map(item => item.lineId), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID()}
|
||||
const payload = pendingSave.value
|
||||
if (payload.appointmentNo) await updateFbaAppointment(payload.appointmentNo, { details: payload.details, version: payload.version, idempotencyKey: payload.idempotencyKey })
|
||||
else await createFbaAppointment({ originWarehouseId: payload.originWarehouseId, fbaWarehouseCode: payload.fbaWarehouseCode, details: payload.details, idempotencyKey: payload.idempotencyKey, lineIds: payload.lineIds })
|
||||
pendingSave.value = null
|
||||
dialogOpen.value = false
|
||||
ElMessage.success('预约已保存')
|
||||
await load()
|
||||
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试') }
|
||||
await Promise.all([load(), loadUnscheduled()])
|
||||
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(dialogError.value.code || '')) pendingSave.value = null }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
@@ -255,17 +268,17 @@ async function remove(event: CalendarEvent) {
|
||||
const appointment = appointments.value.find(item => item.appointmentNo === event.key)
|
||||
if (!appointment || loading.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除预约 ${appointment.appointmentNo}?该操作会解除全部 ${appointment.selectedCount} 票货物的预约关联。`, '删除预约',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
|
||||
await ElMessageBox.confirm(`确认取消预约 ${appointment.appointmentNo}?将释放全部 ${appointment.selectedCount} 票货物的占用,历史仍保留。`, '取消预约',
|
||||
{ type: 'warning', confirmButtonText: '确认取消预约', cancelButtonText: '返回', customClass: 'admin-density-confirm' })
|
||||
} catch { return }
|
||||
loading.value = true
|
||||
try {
|
||||
await deleteAppointment(appointment.appointmentNo, appointment.version)
|
||||
ElMessage.success('预约已删除')
|
||||
ElMessage.success('预约已取消,历史仍保留')
|
||||
await load()
|
||||
} catch (error) {
|
||||
errorTitle.value = '预约删除失败'
|
||||
loadError.value = getApiErrorDetails(error, '预约删除失败')
|
||||
errorTitle.value = '预约取消失败'
|
||||
loadError.value = getApiErrorDetails(error, '预约取消失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -291,8 +304,8 @@ async function resolveLink() {
|
||||
}
|
||||
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
|
||||
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
|
||||
onMounted(() => { void load(); void loadUnscheduled() })
|
||||
onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -305,14 +318,16 @@ onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest;
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="workView = 'containers'">整柜提柜与接仓</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">已开船 FBA 货物</button>
|
||||
</div>
|
||||
<AppointmentCargoPanel v-if="workView === 'cargo'" />
|
||||
<AppointmentCargoPanel v-if="workView === 'cargo'" @created="load(); loadUnscheduled()" />
|
||||
<AppointmentContainerPanel v-if="workView === 'containers'" />
|
||||
<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>
|
||||
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
|
||||
<details v-if="unscheduledTotal" class="appointment-extra"><summary>未定时安排({{ unscheduledTotal }})</summary><DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" row-key="appointmentNo" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()"><template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template></DataTable></details>
|
||||
<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">
|
||||
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
|
||||
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
||||
<div v-loading="detailLoading">
|
||||
<el-form label-position="top" class="calendar-form density-form">
|
||||
@@ -322,21 +337,10 @@ onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest;
|
||||
<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>
|
||||
<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>
|
||||
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="saving || !!pendingSave" />
|
||||
<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 #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import AppDialog from '@/shared/components/AppDialog.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'
|
||||
@@ -7,14 +10,58 @@ 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 { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const emit = defineEmits<{ created: [] }>()
|
||||
const arrangement = reactive(createAppointmentForm())
|
||||
const creating = ref(false), createError = ref<ApiErrorDetails | null>(null)
|
||||
const pendingCreate = ref<SourceAppointmentPayload | null>(null)
|
||||
const following = ref<ResolvedPlanningSource | null>(null)
|
||||
const followupError = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<Record<string, PlanningCargo>>({})
|
||||
const selectedRows = computed(() => Object.values(selected.value))
|
||||
const batchPreview = ref<PlanningBatchPreview | null>(null)
|
||||
const batchLoading = ref(false), batchError = ref<ApiErrorDetails | null>(null)
|
||||
let batchRequest = 0
|
||||
function selectable(row: PlanningCargo) {
|
||||
const anchor = selectedRows.value[0]
|
||||
return !row.detained && !row.mixedDestinations && !row.hasActiveAppointment && (!anchor || (anchor.originWarehouseId === row.originWarehouseId && anchor.destinationWarehouseCode === row.destinationWarehouseCode))
|
||||
}
|
||||
function toggle(row: PlanningCargo, checked: unknown) {
|
||||
batchError.value = null
|
||||
if (checked && selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row
|
||||
else delete selected.value[row.sourceKey]
|
||||
}
|
||||
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
|
||||
async function previewBatch() {
|
||||
if (!selectedRows.value.length || batchLoading.value) return
|
||||
const current = ++batchRequest
|
||||
batchLoading.value = true; batchError.value = null
|
||||
try {
|
||||
const result = await previewPlanningBatch(selectedRows.value.map(row => ({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })))
|
||||
if (current === batchRequest) { batchPreview.value = result; Object.assign(arrangement, createAppointmentForm()); pendingCreate.value = null; createError.value = null }
|
||||
} catch (cause) { if (current === batchRequest) batchError.value = getApiErrorDetails(cause, '所选货物暂不能合批') }
|
||||
finally { if (current === batchRequest) batchLoading.value = false }
|
||||
}
|
||||
async function saveArrangement() {
|
||||
if (!batchPreview.value || creating.value || !access.can(overseasWarehousePermissions.inboundOrderAppointment)) return
|
||||
const error = appointmentFormError(arrangement)
|
||||
if (!pendingCreate.value && error) { ElMessage.warning(error); return }
|
||||
pendingCreate.value ||= { originWarehouseId: batchPreview.value.group.originWarehouseId, fbaWarehouseCode: batchPreview.value.destinationWarehouseCode, sources: batchPreview.value.sources.map(row => ({sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: row.destinationKey})), details: appointmentPayload(arrangement), idempotencyKey: crypto.randomUUID() }
|
||||
creating.value = true; createError.value = null
|
||||
try {
|
||||
const result = await createSourceAppointment(pendingCreate.value)
|
||||
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('created'); await load()
|
||||
} catch (cause) {
|
||||
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
let followupRequest = 0
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 }]
|
||||
@@ -26,6 +73,7 @@ let request = 0
|
||||
let controller: AbortController | undefined
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' },
|
||||
{ key: 'select', label: '选择', slotName: 'select', width: 65 },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 150 },
|
||||
@@ -63,18 +111,26 @@ async function openFollowup(row: PlanningCargo) {
|
||||
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
|
||||
}
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; ++followupRequest; controller?.abort() })
|
||||
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; 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 #toolbar-start>已选 {{ selectedRows.length }} 票,可跨客户选择同一起运仓、同一目的仓货物</template>
|
||||
<template #toolbar-actions>
|
||||
<el-button :disabled="loading || batchLoading" @click="selectPage">选择本页可合批货物</el-button>
|
||||
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||
</template>
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false"><template #details><ApiErrorMeta :details="batchError" /></template></LoadErrorState>
|
||||
<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 #select="{ row }"><el-checkbox :model-value="!!selected[row.sourceKey]" :aria-label="`选择 ${row.inboundCode || '未关联入仓码'}`" :disabled="batchLoading || (!selected[row.sourceKey] && (!selectable(row as PlanningCargo) || selectedRows.length >= 100))" @change="value => toggle(row as PlanningCargo, value)" /></template>
|
||||
<template #progress="{ row }">
|
||||
<span v-if="row.detained">暂扣,待处理</span>
|
||||
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
|
||||
@@ -83,6 +139,14 @@ onBeforeUnmount(() => { ++request; ++followupRequest; controller?.abort() })
|
||||
</template>
|
||||
</DataTable>
|
||||
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" @close="following = null" />
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="batchPreview = null">
|
||||
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
|
||||
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
|
||||
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
|
||||
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
|
||||
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
|
||||
<FbaAppointmentFields :model="arrangement" :disabled="creating || !!pendingCreate" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -13,6 +13,7 @@ const emit = defineEmits<{ close: [] }>()
|
||||
const stages: Record<FollowupStage, string> = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
|
||||
const coverageNeeds: Record<string, string> = {
|
||||
DESTINATION: '目的仓与原安排不一致,请核对。',
|
||||
DESTINATION_UNVERIFIED: '来源目的地资料待核实,尚不能判断是否发生变更。',
|
||||
CARRIER_CONFIRMATION: '尚未确认承运,请先在原登记完成确认。',
|
||||
REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先记录联系结果。',
|
||||
CARGO_QUANTITY_REVIEW: '货量或重复关联需要核对。',
|
||||
|
||||
@@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||
import { getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn() }))
|
||||
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] }
|
||||
@@ -15,6 +15,20 @@ const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], ch
|
||||
|
||||
describe('appointment tables use actual shared table slots', () => {
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
it('selects multiple customers and makes one authoritative batch check without per-row resolution', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo, { ...cargo, sourceKey: 'ORDER:2', orderId: '2', inboundCode: 'IN-B', customerId: 'B', customerName: '客户乙' }], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: 40, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] })
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text().startsWith('合批核对'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'ORDER:2', customerId: 'B', destinationKey: 'FBA:ONT8' }])
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('2 票 / 2 个客户 / 预报 40 箱')
|
||||
expect(wrapper.text()).toContain('保存前尚未占用货物')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders one shared container and resolves each receiving warehouse without per-row requests', async () => {
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] })
|
||||
vi.mocked(resolvePlanningContainer).mockResolvedValue({ containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId: '100', warehouseName: '洛杉矶仓', canFollowup: true, blockReasons: [] })
|
||||
|
||||
@@ -7,18 +7,20 @@
|
||||
| 页面/操作 | 原型承载与布局 | 实现约束 |
|
||||
|---|---|---|
|
||||
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
|
||||
| 新增预约 | 居中双列表单,原型最大宽度760px | 按2026-10-09用户简化要求,改用居中1080px三列表单;仓库从启用FBA主档远程搜索选择,选择后自动分页加载货物;主区保留仓库、送仓时间、预约编号、预计装车时间,结束时间/备注/新建默认预约中状态收至补充信息,在同弹框下方分页选择真实入仓货物 |
|
||||
| 新增预约 | 居中表单,按用户简化要求使用1080px三列 | 仓库远程搜索并自动加载货物;主区保留计划日期、登记阶段及可选备注。系统单号自动生成,不要求外部约号和预计装车时间;计划时段收至补充信息。选择“已取得对方正式确认”才展开外部号和确认时段,并显示地点时区 |
|
||||
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
|
||||
| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 |
|
||||
| 删除预约 | 二次确认 | 显式提示全部货物解除预约关联;独立删除权限,已关联有效排车拒绝删除 |
|
||||
| 取消预约 | 二次确认 | 显式提示全部货物释放占用、历史保留;沿用独立权限,已关联有效排车时先处理运输安排,不能自动回退库存 |
|
||||
| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 |
|
||||
| 多条安排 | 日历标签、右侧明细 | 月/周日格均最多显示3条,超出显示“还有N条”,悬停显示当天总数;日格固定高度,右侧全部展示并独立滚动 |
|
||||
| 多入仓码 | 用户2026-10-09补充截图:单预约20票导致两列详情被长字符串撑高 | 右侧卡片与编辑弹框共用独立整行入仓码列表;去空去重后显示总数,默认3条,长码悬停可读全文;超过3条可展开/收起,展开后216px内独立滚动并可键盘聚焦,不拼成长段文字 |
|
||||
| 在途货物及外接来源跟进 | 美国预约统一方案的提前联系需求 | 日历与已开船FBA列表切换;跟进在居中1080px弹框中承载,3列表单、联系结果及下一步整行,历史分页;BOOKING深链先由服务端核对,再展示原登记及真实预约缺项 |
|
||||
| 整柜提柜与接仓 | 美国预约统一方案的整柜共享事项 | 独立工作视图按柜汇总,操作列左置;接仓点在行内分组,点击后共用1080px跟进弹框。整柜只选拖车/码头事项,接仓只记录对应仓库,单票只记录FBA联系;数量未知或范围不完整明确显示,不按客户复制整柜联系。首次只请求一页整柜数据,详情按点击读取;待1920×1080页面验收 |
|
||||
| 在途跨客户合批安排 | 美国预约统一方案的同起运仓、同目的仓合批 | 操作列后选择同起运仓及同目的仓,跨页累计最多100票;批量核对后在1080px弹框填写与入仓入口共用的安排表单。未知箱数保留待核实;暂扣、多目的地和已有预约不可选入。保存必须重新核对,未知结果保留同一命令供重试 |
|
||||
| 未定时安排 | 总览不得伪造时段 | 日历上方独立折叠分页列表;日期未定不补零点或09点,继续编辑仍操作原系统单号;仅有计划日期时日历显示日期,不生成确认时段 |
|
||||
| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
|
||||
|
||||
预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选在选仓后自动分页读取,跨页选择保留;换仓立即清空旧数据/选择并丢弃旧请求响应,清空仓库后不发送货物查询。后端必填的预计装车时间保留,不自动伪造;预约编号继续填写仓库提供的业务号。仓库请求失败可重试;仅展示前50个匹配仓,超出提示缩小关键词。
|
||||
结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约与到港各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。
|
||||
|
||||
本轮视觉验收:待人工1920×1080截图确认。
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { FbaAppointmentDetailsPayload, FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
|
||||
export interface PlanningCargo {
|
||||
sourceKey: string
|
||||
@@ -64,6 +65,27 @@ export interface AppointmentCoverage {
|
||||
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 interface PlanningBatchPreview {
|
||||
group: { originWarehouseId: string; destinationKey: string; packages: number | null; sourceCount: number; customerCount: number; readinessIncomplete: boolean; readyFrom: string | null; readyTo: string | null; unknownQuantitySources: number }
|
||||
originWarehouseName: string
|
||||
destinationWarehouseCode: string
|
||||
sources: PlanningSource[]
|
||||
missingFields: string[]
|
||||
}
|
||||
export async function previewPlanningBatch(sources: Array<{sourceKey: string; customerId: string; destinationKey: string}>) {
|
||||
const result = unwrapApiResponse(await http.post<ApiEnvelope<PlanningBatchPreview>, ApiEnvelope<PlanningBatchPreview>>(
|
||||
'/platform/v1/last-leg/appointment-planning/batches/preview', { sources },
|
||||
))
|
||||
return { ...result, group: { ...result.group, packages: result.group.packages == null ? null : Number(result.group.packages), sourceCount: Number(result.group.sourceCount), customerCount: Number(result.group.customerCount) } }
|
||||
}
|
||||
export interface SourceAppointmentPayload { originWarehouseId: string; fbaWarehouseCode: string; sources: Array<{sourceKey: string; customerId: string; destinationKey: string}>; details: FbaAppointmentDetailsPayload; idempotencyKey: string }
|
||||
export async function createSourceAppointment(payload: SourceAppointmentPayload) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<FbaAppointmentDto>, ApiEnvelope<FbaAppointmentDto>>('/platform/v1/last-leg/appointment-planning/appointments', payload))
|
||||
}
|
||||
export async function listUnscheduledAppointments(page: number, size: number) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: FbaAppointmentDto[]; total: number}>, ApiEnvelope<{records: FbaAppointmentDto[]; total: number}>>('/platform/v1/last-leg/appointment-planning/appointments/unscheduled', { params: { page, size } }))
|
||||
return { ...result, total: Number(result.total) }
|
||||
}
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user