feat(appointments): simplify planning and preserve source warehouse intent

This commit is contained in:
2026-10-10 10:44:07 +08:00
parent fb062b5ec4
commit 901431f17a
26 changed files with 423 additions and 108 deletions
+56
View File
@@ -242,6 +242,62 @@ describe('admin auth session', () => {
expect(localStorage.getItem('cc-admin-token')).toBeNull()
})
it('rejects an old refresh after another tab has switched to a different account', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'admin-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
let finishRefresh!: (value: ReturnType<typeof loginResponse>) => void
mocks.post.mockImplementation(() => new Promise(resolve => { finishRefresh = resolve }))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const listeners = vi.spyOn(window, 'addEventListener')
const auth = useAuthStore()
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
listeners.mockRestore()
const refreshing = auth.refreshSession()
await Promise.resolve()
const salesToken = token({ user_id: '2000002', username: 'sales' })
localStorage.setItem('cc-admin-token', salesToken)
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
expect(auth.user?.account).toBe('sales')
finishRefresh(loginResponse(token(), 'admin-rotated'))
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.user?.account).toBe('sales')
expect(auth.token).toBe(salesToken)
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
})
it('discards late permission results after another tab switches accounts without clearing the new session', async () => {
mocks.post.mockResolvedValue(loginResponse())
let finishPermissions!: (value: string[]) => void
let permissionsStarted!: () => void
const started = new Promise<void>(resolve => { permissionsStarted = resolve })
mocks.listCurrentPlatformPermissionCodes.mockImplementation(() => {
permissionsStarted()
return new Promise(resolve => { finishPermissions = resolve })
})
const listeners = vi.spyOn(window, 'addEventListener')
const auth = useAuthStore()
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
listeners.mockRestore()
const login = auth.login('platform_admin', 'password')
await started
const salesToken = token({ user_id: '2000002', username: 'sales' })
localStorage.setItem('cc-admin-token', salesToken)
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
finishPermissions(['platform:warehouse:list'])
await expect(login).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.user?.account).toBe('sales')
expect(auth.token).toBe(salesToken)
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
expect(mocks.post).toHaveBeenCalledTimes(1)
})
it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => {
const previous = token()
const rotated = token({ token_version: 2 })
+4
View File
@@ -144,6 +144,10 @@ export const useAuthStore = defineStore('admin-auth', () => {
try {
const session = sessionFromToken(storedToken)
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
// A different account in another tab supersedes this tab's pending login/refresh responses.
if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) {
sessionGeneration += 1
}
token.value = storedToken
refreshToken.value = storedRefreshToken
user.value = session.user
@@ -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 },
@@ -142,6 +142,7 @@
| 客户货物登记 | 新增、补充资料、只读跟进 | 1080px居中三列 | 首屏客户/目的地/已知货量;确认前必填折叠但显示缺项;始终选填独立;单站自动带入 |
| 客户货物登记 | 确认承运、取消 | 既有居中确认框 | 展示对象及保位/商业订单影响,保存成功不冒充确认成功 |
| 客户货物登记 | 安排来货、核量、排入本车 | 1080px居中,复用履约表单 | 原登记连续处理,按服务端资格;关联业务按权限跳转 |
| 客户货物登记 | 查看排车详情、前往车次审核 | 只读跟进弹框跳转既有独立详情 | 排车与车次分别按各自list权限展示;审核仍由车次独立写权限控制,不在排车只读页暗示可审核;待TEST发布后1920×1080复验 |
| 车次准备 | 选择合格排车、新建或加入车次、连续发布 | 1080px居中,复用车次与发布表单 | 来源带入、版本和失败续办,未授权的预约事实不由此生成 |
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
@@ -125,7 +125,8 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<el-button v-if="inquiry.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-confirm')" @click="emit('action', 'receipt', inquiry)">核对实收与运费</el-button>
<el-button v-if="inquiry.progress.primaryAction === 'BIND' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch')" @click="emit('action', 'bind', inquiry)">确认排入本车</el-button>
<el-button v-if="inquiry.incomingNo" @click="emit('action', 'fulfillment', inquiry)">查看收货进度</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('action', 'dispatch', inquiry)">查看排车与审核</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('action', 'dispatch', inquiry)">查看排车详情</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:fleet:trip:list')" @click="emit('action', 'trip', inquiry)">前往车次审核</el-button>
</section>
<section v-if="inquiry?.appointmentCoverage" aria-label="收货预约覆盖">
<h3>收货预约覆盖</h3><el-button v-if="access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')" @click="emit('action', 'appointment', inquiry)">预约跟进</el-button><p>{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。</p>
@@ -23,6 +23,7 @@ const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>
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 (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); 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
@@ -10,9 +10,9 @@ import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import * as api from '../api/capacityOffers'
import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, route: { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, links: new Set<string>(), route: { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
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('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => state.links.has(p) || (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(), 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 } }
@@ -35,7 +35,7 @@ function mount(component: object, props: Record<string, unknown> = {}) {
} } }); wrappers.push(wrapper); return { wrapper, pinia }
}
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
@@ -161,3 +161,33 @@ it('requires an explicit choice for multiple destinations', async () => {
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
})
// Navigation must retain the exact bound business references and independent read permissions.
it.each([CapacityOffersView, CapacityOfferDetailView])('opens the bound trip separately from read-only dispatch detail', async component => {
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
state.links = new Set(['platform:fleet:trip:list', 'platform:overseas-warehouse:dispatch:list'])
const { wrapper } = mount(component); await flushPromises()
const dialog = wrapper.findComponent(CapacityInquiryDialog)
dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' } })
dialog.vm.$emit('action', 'trip', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
state.links.clear(); state.push.mockClear()
dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
expect(state.push).not.toHaveBeenCalled()
})
it('shows follow-up destinations only with the matching read permission', async () => {
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
state.links.add('platform:overseas-warehouse:dispatch:list')
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true }); await flushPromises()
expect(button(wrapper, '查看排车详情')).toBeDefined()
expect(button(wrapper, '前往车次审核')).toBeUndefined()
wrapper.unmount()
state.links = new Set(['platform:fleet:trip:list'])
const second = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true }); await flushPromises()
expect(button(second.wrapper, '查看排车详情')).toBeUndefined()
await button(second.wrapper, '前往车次审核').trigger('click')
expect(second.wrapper.emitted('action')).toEqual([['trip', row]])
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
@@ -26,6 +26,7 @@ const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>
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 (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); 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
@@ -11,7 +11,7 @@
| 柜级计划进度/详情 | `GET /platform/v1/last-leg/orders/container-summaries`;`GET /container-summaries/{sourceContainerBookingId}` | summary:`sourceContainerBookingId,containerNo?,totalOrderCount,inboundCodeCount,missingInboundCodeCount,planCount,plannedInboundCodeCount,pendingPlanCount,plannedArrivalAt?,allPlansReady,blockingReason?`;list query:`page,size,containerNo?,createdFrom?,createdTo?`;detail:`summary,orders[]`,订单与计划 ID 均为字符串 | 独立“柜子”Tab 消费服务端全柜分页,默认按计划到仓升序且 null 最后;详情抽屉显示柜内全部订单、入仓码和独立计划状态;同一摘要也供按柜生成选择框,不从订单分页本地拼柜 |
| 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户;每行返回计划级 `assigned/mergeAllowed/urgent` | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串;合托展示为可选提示,不作为完成门禁 |
| 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码;`allocations[]` 直接返回实际 `palletNo`、`palletOwnerPlanNo`、`palletOwnedByCurrentPlan` | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化;跨计划合托按分配事实展示真实托盘码、所属计划和本单箱数,不把外部托盘计入当前计划自有托盘数 |
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`lineIds:string[],details,idempotencyKey`;details区分计划日期/时段、确认号/时段及状态 | `...:inbound-order:fba-appointment`;跨客户同美国起运仓、同FBA仓;服务端将选择明细转换成原稳定货源并生成系统单号,不输入主单客户或预计装车时间 |
| FBA 预约详情/编辑 | `GET /fba-appointments/{appointmentNo}?customerId=...`;`PUT` 同 path | PUT body:`customerId,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | GET 同 list;PUT 同 appointment;失败保留弹框和错误元数据 |
| 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 |
| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?`;退回候选返回`sourceType=RETURN_RECEIPT,returnReceiptId,returnReceiptNo`,箱数为实际剩余可拣量 | 同 inbound list;已实收阶段包含真实退回来源,展示业务凭据号;草稿恢复失败保留选择并允许重试 |
@@ -33,7 +33,7 @@ describe('inbound order overseas API', () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: detail })
await getFbaAppointment('APT/1')
await updateFbaAppointment('APT/1', { version: detail.version, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' })
await updateFbaAppointment('APT/1', { version: detail.version, idempotencyKey: 'update-key', details: {appointmentAt: detail.appointmentAt, confirmationNo: 'EXT-1', appointmentStatus: 'SUCCESS'} })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1')
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.not.objectContaining({ customerId: expect.anything() }))
@@ -50,11 +50,9 @@ describe('inbound order overseas API', () => {
it('preserves selected line ids as strings when creating an appointment', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', appointmentNo: 'APT-1', appointmentStatus: 'SUCCESS', selectedCount: 1, fbaWarehouseCode: 'ONT8' } })
const payload = {
lineIds: ['190000000000000099'],
appointmentNo: 'APT-1',
appointmentAt: '2026-08-20T10:30:00',
expectedLoadingAt: '2026-08-20T09:00:00',
appointmentStatus: 'SUCCESS' as const,
originWarehouseId: '100', fbaWarehouseCode: 'ONT8', lineIds: ['190000000000000099'],
idempotencyKey: 'create-key',
details: { plannedDate: '2026-08-20', appointmentStatus: 'PENDING' as const },
}
await createFbaAppointment(payload)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/batch', payload)
@@ -53,31 +53,48 @@ export interface InboundOrderSummaryDto {
version?: number | null
}
export interface BatchFbaAppointmentPayload {
lineIds: string[]
appointmentNo: string
appointmentAt: string
export interface FbaAppointmentDetailsPayload {
plannedDate?: string | null
plannedAt?: string | null
plannedEndAt?: string | null
confirmationNo?: string | null
appointmentAt?: string | null
appointmentEndAt?: string | null
expectedLoadingAt: string
appointmentStatus: 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'
appointmentRemark?: string
appointmentRemark?: string | null
startOffset?: string | null
endOffset?: string | null
}
export interface BatchFbaAppointmentPayload {
originWarehouseId: string
fbaWarehouseCode: string
lineIds: string[]
details: FbaAppointmentDetailsPayload
idempotencyKey: string
}
export interface FbaAppointmentDto {
version: number
id: string
appointmentNo: string
appointmentAt: string
confirmationNo?: string | null
plannedDate?: string | null
plannedAt?: string | null
plannedEndAt?: string | null
appointmentAt?: string | null
appointmentEndAt?: string | null
expectedLoadingAt: string
timezone?: string | null
appointmentStartUtc?: string | null
appointmentEndUtc?: string | null
originWarehouseId: string
appointmentStatus: string
appointmentRemark?: string | null
selectedCount: number
fbaWarehouseCode: string
items: Array<{ customerId: string; id: string; planId: string; lineId: string; orderId: string; inboundCode: string; fbaWarehouseCode: string }>
items: Array<{ customerId: string; id: string; sourceKey: string; sourceVersion?: string | null; orderId?: string | null; inboundCode?: string | null; fbaWarehouseCode: string; packageCount?: number | null }>
}
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'> & { version: number }
export interface UpdateFbaAppointmentPayload { details: FbaAppointmentDetailsPayload; version: number; idempotencyKey: string }
export async function listFbaAppointmentCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<FbaAppointmentDto[]>, ApiEnvelope<FbaAppointmentDto[]>>(
@@ -0,0 +1,15 @@
import { expect, it } from 'vitest'
import { appointmentFormError, appointmentPayload, createAppointmentForm } from './appointmentForm'
it('allows an undated arrangement without inventing confirmation or loading facts', () => {
const form = createAppointmentForm()
expect(appointmentFormError(form)).toBeNull()
expect(appointmentPayload(form)).toMatchObject({ plannedDate: null, plannedAt: null, appointmentAt: null, confirmationNo: null, appointmentStatus: 'PENDING' })
expect(appointmentPayload(form)).not.toHaveProperty('expectedLoadingAt')
})
it('retains an already confirmed repeated local time using its stored UTC instant', () => {
const form = createAppointmentForm({ id: '1', version: 0, appointmentNo: 'AP-1', confirmationNo: 'EXT-1', originWarehouseId: '100', fbaWarehouseCode: 'ONT8', appointmentStatus: 'SUCCESS', selectedCount: 1, items: [], appointmentAt: '2026-11-01T01:30:00', appointmentStartUtc: '2026-11-01T09:30:00Z', timezone: 'America/Los_Angeles' })
expect(form.startOffset).toBe('-08:00')
expect(appointmentPayload(form).endOffset).toBeNull()
expect(appointmentPayload(form).appointmentAt).toBe('2026-11-01T01:30:00')
})
@@ -0,0 +1,28 @@
import type { FbaAppointmentDetailsPayload, FbaAppointmentDto } from './api/inboundOrders'
export interface AppointmentForm {
plannedDate: string; plannedAt: string; plannedEndAt: string; confirmationNo: string
appointmentAt: string; appointmentEndAt: string; appointmentStatus: FbaAppointmentDetailsPayload['appointmentStatus']
appointmentRemark: string; startOffset: string; endOffset: string
}
export function createAppointmentForm(detail?: FbaAppointmentDto): AppointmentForm {
return { plannedDate: detail?.plannedDate || '', plannedAt: detail?.plannedAt || '', plannedEndAt: detail?.plannedEndAt || '', confirmationNo: detail?.confirmationNo || '',
appointmentAt: detail?.appointmentAt || '', appointmentEndAt: detail?.appointmentEndAt || '', appointmentStatus: (detail?.appointmentStatus || 'PENDING') as AppointmentForm['appointmentStatus'],
appointmentRemark: detail?.appointmentRemark || '', startOffset: confirmedOffset(detail?.appointmentAt, detail?.appointmentStartUtc), endOffset: confirmedOffset(detail?.appointmentEndAt, detail?.appointmentEndUtc) }
}
function confirmedOffset(local?: string | null, utc?: string | null): string {
if (!local || !utc) return ''
const minutes = (Date.parse(`${local}Z`) - Date.parse(utc)) / 60000
if (!Number.isInteger(minutes) || Math.abs(minutes) > 18 * 60) return ''
return `${minutes < 0 ? '-' : '+'}${String(Math.floor(Math.abs(minutes) / 60)).padStart(2, '0')}:${String(Math.abs(minutes) % 60).padStart(2, '0')}`
}
export function appointmentPayload(form: AppointmentForm): FbaAppointmentDetailsPayload {
return { plannedDate: form.plannedDate || null, plannedAt: form.plannedAt || null, plannedEndAt: form.plannedEndAt || null, confirmationNo: form.confirmationNo.trim() || null,
appointmentAt: form.appointmentAt || null, appointmentEndAt: form.appointmentEndAt || null, appointmentStatus: form.appointmentStatus, appointmentRemark: form.appointmentRemark.trim() || null,
startOffset: form.startOffset.trim() || null, endOffset: form.endOffset.trim() || null }
}
export function appointmentFormError(form: AppointmentForm): string | null {
if (form.appointmentStatus === 'SUCCESS' && (!form.confirmationNo.trim() || !form.appointmentAt)) return '登记对方确认时,请填写外部约号与确认时间'
if (['FAILED', 'CANCELLED'].includes(form.appointmentStatus) && !form.appointmentRemark.trim()) return '请填写取消或未约成的原因'
return null
}
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { AppointmentForm } from '../appointmentForm'
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean }>()
</script>
<template>
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="appointmentNo" label="取消安排" value="CANCELLED" /></el-select></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
<el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
<el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
</template>
<el-form-item :label="['FAILED','CANCELLED'].includes(model.appointmentStatus) ? '原因' : '备注'" :required="['FAILED','CANCELLED'].includes(model.appointmentStatus)"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item>
<details class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item>
<el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item>
</template>
</details>
</el-form>
</template>
<style scoped>
.appointment-fields { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 var(--admin-space-6); }
.appointment-fields :deep(.el-select), .appointment-fields :deep(.el-date-editor) { width:100%; }
.appointment-fields__extra { grid-column:1 / -1; }
</style>
+3 -1
View File
@@ -2,5 +2,7 @@ export {
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar,
listInboundOrders, updateFbaAppointment,
} from './api/inboundOrders'
export type { FbaAppointmentDto, BatchFbaAppointmentPayload, InboundOrderSummaryDto } from './api/inboundOrders'
export type { FbaAppointmentDto, BatchFbaAppointmentPayload, UpdateFbaAppointmentPayload, FbaAppointmentDetailsPayload, InboundOrderSummaryDto } from './api/inboundOrders'
export { default as FbaAppointmentFields } from './components/FbaAppointmentFields.vue'
export { createAppointmentForm, appointmentPayload, appointmentFormError } from './appointmentForm'
export { overseasWarehousePermissions } from './permissions'
@@ -15,6 +15,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -53,7 +55,9 @@ const editingAppointmentNo = ref('')
const appointmentSelectedCount = ref(0)
const appointmentFbaWarehouse = ref('—')
const appointmentVersion = ref(0)
const appointmentForm = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' })
const appointmentForm = reactive(createAppointmentForm())
const appointmentTimezone = ref<string | null>(null)
const pendingAppointment = ref<{appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: ReturnType<typeof appointmentPayload>; idempotencyKey: string} | null>(null)
const appointmentTitle = computed(() => appointmentMode.value === 'edit' ? '编辑预约' : '预约')
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
@@ -61,7 +65,7 @@ function selectableRow(source: object) {
const row = source as InboundOrderListItem
if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false
const anchor = selectedRows.value[0]
return !anchor || anchor.fbaWarehouse === row.fbaWarehouse
return !anchor || (anchor.fbaWarehouse === row.fbaWarehouse && anchor.originWarehouseId === row.originWarehouseId)
}
const columns: DataTableColumn[] = [
@@ -102,7 +106,7 @@ function appointmentStatusLabel(value: unknown) { return !value ? '未预约' :
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
return {
lineId: String(dto.lineId), planId: String(dto.planId), customerId: String(dto.customerId), orderId: String(dto.orderId),
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode), destinationWarehouseName: text(dto.warehouseName),
originWarehouseId: String(dto.overseasWarehouseId || ''), warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode), destinationWarehouseName: text(dto.warehouseName),
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount),
grossWeight: number(dto.grossWeightKg), netWeight: number(dto.netWeightKg), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
@@ -117,6 +121,7 @@ function onSelectionChange(selection: object[]) { selectedRows.value = selection
function selectionError() {
if (!selectedRows.value.length) return '请至少选择 1 条入库单'
if (selectedRows.value.some((row) => !row.fbaWarehouse || row.fbaWarehouse === '—')) return '所选入库单缺少 FBA 仓库信息'
if (selectedRows.value.some(row => !row.originWarehouseId) || new Set(selectedRows.value.map(row => row.originWarehouseId)).size !== 1) return '所选入库单必须属于同一接仓点'
if (new Set(selectedRows.value.map((row) => row.fbaWarehouse)).size !== 1) return '所选入库单必须属于同一 FBA 仓库'
return ''
}
@@ -127,7 +132,7 @@ function openAppointment() {
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
appointmentVersion.value = 0
Object.assign(appointmentForm, { appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS', appointmentRemark: '' })
Object.assign(appointmentForm, createAppointmentForm()); appointmentTimezone.value = null; pendingAppointment.value = null
mutationError.value = null
appointmentOpen.value = true
}
@@ -146,7 +151,7 @@ async function openAppointmentEdit(source: object) {
appointmentSelectedCount.value = detail.selectedCount
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
appointmentVersion.value = detail.version
Object.assign(appointmentForm, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus, appointmentRemark: detail.appointmentRemark || '' })
Object.assign(appointmentForm, createAppointmentForm(detail)); appointmentTimezone.value = detail.timezone || null; pendingAppointment.value = null
} catch (caught) {
appointmentOpen.value = false
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
@@ -155,14 +160,17 @@ async function openAppointmentEdit(source: object) {
async function submitAppointment() {
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
if (!appointmentForm.appointmentNo.trim() || !appointmentForm.appointmentAt || !appointmentForm.expectedLoadingAt) { ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return }
const validation = appointmentFormError(appointmentForm)
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
saving.value = true; mutationError.value = null
try {
const common = { appointmentEndAt: appointmentForm.appointmentEndAt || null, appointmentAt: appointmentForm.appointmentAt, expectedLoadingAt: appointmentForm.expectedLoadingAt, appointmentStatus: appointmentForm.appointmentStatus, appointmentRemark: appointmentForm.appointmentRemark.trim() || undefined }
if (appointmentMode.value === 'edit') await updateFbaAppointment(editingAppointmentNo.value, { ...common, version: appointmentVersion.value })
else await createFbaAppointment({ lineIds: selectedRows.value.map((row) => row.lineId), appointmentNo: appointmentForm.appointmentNo.trim(), ...common })
pendingAppointment.value ||= {appointmentNo: appointmentMode.value === 'edit' ? editingAppointmentNo.value : null, version: appointmentVersion.value, originWarehouseId: selectedRows.value[0]?.originWarehouseId || '', fbaWarehouseCode: selectedRows.value[0]?.fbaWarehouse || '', lineIds: selectedRows.value.map(row => row.lineId), details: appointmentPayload(appointmentForm), idempotencyKey: crypto.randomUUID()}
const payload = pendingAppointment.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, lineIds: payload.lineIds, details: payload.details, idempotencyKey: payload.idempotencyKey })
pendingAppointment.value = null
appointmentOpen.value = false; selectedRows.value = []; ElMessage.success('FBA 预约保存成功'); await load()
} catch (caught) { mutationError.value = getApiErrorDetails(caught, 'FBA 预约保存失败,请重试') }
} catch (caught) { mutationError.value = getApiErrorDetails(caught, 'FBA 预约保存失败,请重试'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(mutationError.value.code || '')) pendingAppointment.value = null }
finally { saving.value = false }
}
@@ -205,17 +213,11 @@ onMounted(() => { void load() })
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" confirm-text="保存预约" :confirm-loading="saving" @confirm="submitAppointment">
<AppDialog class="admin-density-dialog" v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<div v-loading="appointmentLoading">
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
<el-form label-position="top" class="appointment-form density-form">
<el-form-item label="预约号" required><el-input v-model="appointmentForm.appointmentNo" maxlength="64" :readonly="appointmentMode === 'edit'" placeholder="请输入预约号" /></el-form-item>
<el-form-item label="预约送仓时间" required><el-date-picker v-model="appointmentForm.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约送仓时间" /></el-form-item>
<el-form-item label="预约结束时间"><el-date-picker v-model="appointmentForm.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" /></el-form-item>
<el-form-item label="预计装车时间" required><el-date-picker v-model="appointmentForm.expectedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装车时间" /></el-form-item>
<el-form-item label="预约状态" required><el-select v-model="appointmentForm.appointmentStatus" placeholder="请选择预约状态"><el-option label="预约中" value="PENDING" /><el-option label="预约成功" value="SUCCESS" /><el-option label="预约失败" value="FAILED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
<el-form-item label="预约备注"><el-input v-model="appointmentForm.appointmentRemark" maxlength="1000" placeholder="请输入预约备注" /></el-form-item>
</el-form>
<FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="saving || !!pendingAppointment" />
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
@@ -210,7 +210,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain("key: 'selection', type: 'selection'")
expect(value).toContain('@selection-change="onSelectionChange"')
expect(value).toContain('createFbaAppointment')
expect(value).toContain('预计装车时间')
expect(value).toContain('FbaAppointmentFields')
expect(value).not.toContain('appointmentForm.expectedLoadingAt')
expect(value).toContain('所选入库单必须属于同一 FBA 仓库')
expect(value).not.toContain('anchor.customerId === row.customerId')
expect(value).toContain('anchor.fbaWarehouse === row.fbaWarehouse')
+1
View File
@@ -66,6 +66,7 @@ export interface LastMileOrderListItem {
}
export interface InboundOrderListItem {
originWarehouseId: string
lineId: string
planId: string
customerId: string