feat: add appointment calendar and cross-customer booking

This commit is contained in:
2026-10-09 11:01:41 +08:00
parent c90a191d83
commit 2998c62099
20 changed files with 727 additions and 38 deletions
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment } from './inboundOrders'
import { createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment } from './inboundOrders'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
@@ -11,16 +11,32 @@ describe('inbound order overseas API', () => {
vi.mocked(http.put).mockReset()
})
it('loads a calendar range in one request and supports cancelling obsolete requests', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
const controller = new AbortController()
const query = { from: '2026-08-31', to: '2026-10-12' }
expect(await listFbaAppointmentCalendar(query, controller.signal)).toEqual([])
expect(http.get).toHaveBeenCalledTimes(1)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/calendar', {
params: query, signal: controller.signal,
})
})
it('does not turn calendar business errors into an empty successful calendar', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '500', message: '日历查询失败', data: null })
await expect(listFbaAppointmentCalendar({ from: '2026-09-01', to: '2026-10-01' })).rejects.toThrow()
})
it('loads appointment details and updates the existing appointment without replacing its lines', async () => {
const detail = { id: '1', customerId: '190000000000000001', appointmentNo: 'APT/1', appointmentAt: '2026-08-20T10:30:00', expectedLoadingAt: '2026-08-20T09:00:00', appointmentStatus: 'SUCCESS', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }
const detail = { version: 0, id: '1', customerId: '190000000000000001', appointmentNo: 'APT/1', appointmentAt: '2026-08-20T10:30:00', expectedLoadingAt: '2026-08-20T09:00:00', appointmentStatus: 'SUCCESS', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: detail })
await getFbaAppointment('190000000000000001', 'APT/1')
await updateFbaAppointment('APT/1', { customerId: '190000000000000001', appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' })
await getFbaAppointment('APT/1')
await updateFbaAppointment('APT/1', { version: detail.version, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', { params: { customerId: '190000000000000001' } })
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.objectContaining({ customerId: '190000000000000001' }))
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() }))
})
it('loads the paginated list across all customers without a customer scope', async () => {
@@ -34,7 +50,6 @@ 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 = {
customerId: '190000000000000001',
lineIds: ['190000000000000099'],
appointmentNo: 'APT-1',
appointmentAt: '2026-08-20T10:30:00',
@@ -54,29 +54,37 @@ export interface InboundOrderSummaryDto {
}
export interface BatchFbaAppointmentPayload {
customerId: string
lineIds: string[]
appointmentNo: string
appointmentAt: string
appointmentEndAt?: string | null
expectedLoadingAt: string
appointmentStatus: 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'
appointmentRemark?: string
}
export interface FbaAppointmentDto {
version: number
id: string
customerId: string
appointmentNo: string
appointmentAt: string
appointmentEndAt?: string | null
expectedLoadingAt: string
appointmentStatus: string
appointmentRemark?: string | null
selectedCount: number
fbaWarehouseCode: string
items: Array<{ id: string; planId: string; lineId: string; orderId: string; inboundCode: string; fbaWarehouseCode: string }>
items: Array<{ customerId: string; id: string; planId: string; lineId: string; orderId: string; inboundCode: string; fbaWarehouseCode: string }>
}
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'>
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'> & { version: number }
export async function listFbaAppointmentCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<FbaAppointmentDto[]>, ApiEnvelope<FbaAppointmentDto[]>>(
'/platform/v1/last-leg/inbound-plans/fba-appointments/calendar', { params: query, signal },
)
return unwrapApiResponse(response)
}
export async function listInboundOrders(query: InboundOrderQuery) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPagePayload<InboundOrderSummaryDto>>>(
@@ -92,10 +100,9 @@ export async function createFbaAppointment(payload: BatchFbaAppointmentPayload)
return unwrapApiResponse(response)
}
export async function getFbaAppointment(customerId: string, appointmentNo: string) {
export async function getFbaAppointment(appointmentNo: string) {
const response = await http.get<ApiEnvelope<FbaAppointmentDto>, ApiEnvelope<FbaAppointmentDto>>(
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
+6
View File
@@ -0,0 +1,6 @@
export {
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar,
listInboundOrders, updateFbaAppointment,
} from './api/inboundOrders'
export type { FbaAppointmentDto, BatchFbaAppointmentPayload, InboundOrderSummaryDto } from './api/inboundOrders'
export { overseasWarehousePermissions } from './permissions'
@@ -50,10 +50,10 @@ const smartDispatchOpen = ref(false)
const appointmentLoading = ref(false)
const appointmentMode = ref<'create' | 'edit'>('create')
const editingAppointmentNo = ref('')
const editingCustomerId = ref('')
const appointmentSelectedCount = ref(0)
const appointmentFbaWarehouse = ref('—')
const appointmentForm = reactive({ appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' })
const appointmentVersion = ref(0)
const appointmentForm = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' })
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 +61,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.customerId === row.customerId && anchor.fbaWarehouse === row.fbaWarehouse)
return !anchor || anchor.fbaWarehouse === row.fbaWarehouse
}
const columns: DataTableColumn[] = [
@@ -117,7 +117,6 @@ 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 (new Set(selectedRows.value.map((row) => row.customerId)).size !== 1) return '所选入库单必须属于同一客户'
if (new Set(selectedRows.value.map((row) => row.fbaWarehouse)).size !== 1) return '所选入库单必须属于同一 FBA 仓库'
return ''
}
@@ -125,9 +124,10 @@ function selectionError() {
function openAppointment() {
const error = selectionError()
if (error) { ElMessage.warning(error); return }
appointmentMode.value = 'create'; editingAppointmentNo.value = ''; editingCustomerId.value = ''
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
Object.assign(appointmentForm, { appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS', appointmentRemark: '' })
appointmentVersion.value = 0
Object.assign(appointmentForm, { appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS', appointmentRemark: '' })
mutationError.value = null
appointmentOpen.value = true
}
@@ -139,13 +139,14 @@ function openSmartDispatch() {
async function openAppointmentEdit(source: object) {
const row = source as InboundOrderListItem
if (!row.appointmentNo || row.appointmentNo === '—') return
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo; editingCustomerId.value = row.customerId
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo
appointmentLoading.value = true; mutationError.value = null; appointmentOpen.value = true
try {
const detail = await getFbaAppointment(row.customerId, row.appointmentNo)
const detail = await getFbaAppointment(row.appointmentNo)
appointmentSelectedCount.value = detail.selectedCount
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
Object.assign(appointmentForm, { appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus, appointmentRemark: detail.appointmentRemark || '' })
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 || '' })
} catch (caught) {
appointmentOpen.value = false
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
@@ -157,9 +158,9 @@ async function submitAppointment() {
if (!appointmentForm.appointmentNo.trim() || !appointmentForm.appointmentAt || !appointmentForm.expectedLoadingAt) { ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return }
saving.value = true; mutationError.value = null
try {
const common = { appointmentAt: appointmentForm.appointmentAt, expectedLoadingAt: appointmentForm.expectedLoadingAt, appointmentStatus: appointmentForm.appointmentStatus, appointmentRemark: appointmentForm.appointmentRemark.trim() || undefined }
if (appointmentMode.value === 'edit') await updateFbaAppointment(editingAppointmentNo.value, { customerId: editingCustomerId.value, ...common })
else await createFbaAppointment({ customerId: selectedRows.value[0].customerId, lineIds: selectedRows.value.map((row) => row.lineId), appointmentNo: appointmentForm.appointmentNo.trim(), ...common })
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 })
appointmentOpen.value = false; selectedRows.value = []; ElMessage.success('FBA 预约保存成功'); await load()
} catch (caught) { mutationError.value = getApiErrorDetails(caught, 'FBA 预约保存失败,请重试') }
finally { saving.value = false }
@@ -210,6 +211,7 @@ onMounted(() => { void load() })
<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>
@@ -166,7 +166,7 @@ async function reviewPlan(plan: Record<string, unknown>) {
}
async function cancelPlan(plan: Record<string, unknown>) {
try {
await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning' });
await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning', customClass: 'admin-density-confirm' });
await cancelReturnPackingPlan(String(plan.planNo)); ElMessage.success('计划已取消,库存已释放'); await load();
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '取消失败'); }
}
@@ -567,7 +567,7 @@ const tableColumns = computed(() =>
>
<template #filters
><el-tabs
class="density-element-tabs"
class="density-element-tabs density-status-tabs"
v-if="mode === 'inventory'"
v-model="inventoryTab"
@tab-change="changeInventoryTab"
@@ -731,7 +731,7 @@ const tableColumns = computed(() =>
}}</template>
</DataTable>
<AppDialog
class="packing-dialog"
class="packing-dialog admin-density-dialog"
v-model="packingDialogOpen"
title="创建退货合箱计划"
width="min(1380px, calc(100vw - 32px))"
@@ -1000,11 +1000,11 @@ const tableColumns = computed(() =>
padding: 7px 10px;
border-bottom: 1px solid var(--cc-border);
}
.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: 12px; }
.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.packing-sku-row:last-of-type { border-bottom: 0; }
.packing-sku-row > span {
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
white-space: nowrap;
}
.packing-sku-row :deep(.el-input-number) {
@@ -1014,7 +1014,7 @@ const tableColumns = computed(() =>
padding: 0 7px;
}
.packing-sku-row :deep(.el-input__inner) {
font-size: 12px;
font-size: var(--admin-font-caption);
text-align: center;
}
.packing-box-list {
@@ -1041,7 +1041,7 @@ const tableColumns = computed(() =>
top: 0;
z-index: 1;
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
background: var(--cc-surface-subtle, #f8fafc);
border-bottom: 1px solid var(--cc-border);
}
@@ -1055,20 +1055,20 @@ const tableColumns = computed(() =>
.packing-box-list-row > span,
.packing-box-items {
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
}
.packing-box-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.return-nos-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.return-nos-count { color: var(--cc-muted); font-size: var(--admin-font-caption); white-space: nowrap; }
.packing-form :deep(.el-alert) {
margin-top: 2px;
padding: 8px 12px;
}
.packing-form :deep(.el-alert__title) {
font-size: 12px;
font-size: var(--admin-font-caption);
}
@media (max-width: 700px) {
.packing-box-list { overflow-x: auto; }
@@ -26,6 +26,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
const expected = new Map([
['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'],
['InboundOrdersView.vue', 'overseas-inbound-orders-query'],
['CargoDiscardsView.vue', 'overseas-cargo-discards-query'],
['DispatchesView.vue', 'overseas-dispatches-query'],
['DeliveryOrdersView.vue', 'overseas-delivery-orders-query'],
['CustomerVisitsView.vue', 'overseas-customer-visits-query'],
@@ -211,7 +212,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('createFbaAppointment')
expect(value).toContain('预计装车时间')
expect(value).toContain('所选入库单必须属于同一 FBA 仓库')
expect(value).toContain('anchor.customerId === row.customerId')
expect(value).not.toContain('anchor.customerId === row.customerId')
expect(value).toContain('anchor.fbaWarehouse === row.fbaWarehouse')
expect(value).toContain('<CalendarCheck2 :size="16" />预约')
expect(value).toContain('<Truck :size="16" />智能排车')
expect(value).toContain('type="primary" @click="openSmartDispatch"')
@@ -8,6 +8,7 @@ describe('overseas warehouse routes', () => {
['overseas-warehouse/last-mile-orders', permission.lastMileOrderList],
['overseas-warehouse/last-mile-orders/:orderId', permission.lastMileOrderList],
['overseas-warehouse/inbound-orders', permission.inboundOrderList],
['overseas-warehouse/cargo-discards', permission.discardList],
['overseas-warehouse/inbound-orders/:orderId', permission.inboundOrderList],
['overseas-warehouse/dispatches', permission.dispatchList],
['overseas-warehouse/dispatches/:dispatchNo', permission.dispatchList],