feat(appointments): simplify planning and preserve source warehouse intent
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface LastMileOrderListItem {
|
||||
}
|
||||
|
||||
export interface InboundOrderListItem {
|
||||
originWarehouseId: string
|
||||
lineId: string
|
||||
planId: string
|
||||
customerId: string
|
||||
|
||||
Reference in New Issue
Block a user