diff --git a/src/modules/ocean-bookings/PROTOTYPE_MATRIX.md b/src/modules/ocean-bookings/PROTOTYPE_MATRIX.md index 8fa6560a..e97ae2f5 100644 --- a/src/modules/ocean-bookings/PROTOTYPE_MATRIX.md +++ b/src/modules/ocean-bookings/PROTOTYPE_MATRIX.md @@ -11,3 +11,5 @@ | `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 待人工截图确认 | 人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。 + +2026-10-22 用户明确授权 AI 操作 TEST 页面后,已实际核对新增页:1920×1080 下初始仅第 1 步高亮、后续待处理;白底编辑控件、蓝色聚焦边框与真实灰底禁用状态可区分;船公司下拉及仓库—渠道联动可用。截图保存在本机 `C:/Users/BY-OY/.codex/booking-ui-acceptance-20261022/booking-new-progress.png`。保存未通过:独立测试请求返回 S0500,服务日志与只读结构核查确认 TEST 缺少订舱货物/关系方字段且冻结触发器仍为旧版。完整保存、放舱、补实和两类 PDF 预览仍待数据库结构同步后验收,不将本次局部页面核对记为完整流程通过。环境事实以部署主契约“订舱页面 TEST 验收阻断”为准。 diff --git a/src/modules/overseas-warehouse/api/dispatches.test.ts b/src/modules/overseas-warehouse/api/dispatches.test.ts index 6e1ff012..e5193edb 100644 --- a/src/modules/overseas-warehouse/api/dispatches.test.ts +++ b/src/modules/overseas-warehouse/api/dispatches.test.ts @@ -15,6 +15,15 @@ beforeEach(() => { vi.mocked(http.put).mockResolvedValue({ data: command('EDIT') }) vi.mocked(http.delete).mockResolvedValue({ data: draft() }) }) +it('reads and writes original dispatch appointment allocations with lossless versions', async () => { + const value = { dispatchNo: 'PC/1', recordVersion: '9007199254740993', items: [], edit: { allowed: true, blockCode: null, blockReason: null } } + vi.mocked(http.get).mockResolvedValueOnce({ data: value }) + expect(await api.getDispatchAppointmentAllocations('PC/1')).toEqual(value) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations') + const body = { recordVersion: 0, idempotencyKey: 'allocation-key', dispatchItemIds: ['11'], batchAllocations: [{ planPalletId: '21', batchKey: 'FBA_APPOINTMENT:9', customerId: '101', sourceKey: 'ORDER:1', destinationKey: 'FBA:ACY2', packageCount: 10, recordVersion: 0 }] } + await api.saveDispatchAppointmentAllocations('PC/1', body) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations', body) +}) describe('native whole-vehicle dispatch API', () => { it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => { diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts index d0db5ead..9e5a0514 100644 --- a/src/modules/overseas-warehouse/api/dispatches.ts +++ b/src/modules/overseas-warehouse/api/dispatches.ts @@ -1,4 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { AppointmentAllocations, AppointmentAllocationCommand } from '../dispatchContracts' import type { MarkDispatchException, DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts' import { requireVersion, requireBatchVersions, type AddCargo, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts' import { normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire' @@ -17,6 +18,19 @@ export async function listDispatches(query: DispatchQuery) { } export const getDispatch = (dispatchNo: string) => get(path(dispatchNo)).then(normalizeDispatchDetail) export const getDispatchItems = (no: string) => get(`${path(no)}/items`).then(normalizeDispatchItems) +export async function getDispatchAppointmentAllocations(no: string) { + const value = await get(`${path(no)}/appointment-allocations`) + requireVersion(value.recordVersion) + value.items.forEach(item => { requireBatchVersions(item.allocations); item.sources.forEach(source => source.options.forEach(option => requireVersion(option.recordVersion))) }) + return value +} +export const saveDispatchAppointmentAllocations = (no: string, body: AppointmentAllocationCommand) => { + versioned(body); requireBatchVersions(body.batchAllocations) + return postAppointmentAllocations(no, body) +} +async function postAppointmentAllocations(no: string, body: AppointmentAllocationCommand) { + return normalizeDispatchCommand(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(no)}/appointment-allocations`, body))) +} export const getDispatchRoute = (no: string) => get(`${path(no)}/route`) export async function reorderDispatchRoute(no: string, body: DispatchRouteCommand) { return normalizeDispatchCommand(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(no)}/route`, versioned(body)))) } const evidencePath = (no: string, kind: DispatchEvidenceKind, id?: string) => `${path(no)}/evidence/${kind}${id ? `/${encodeURIComponent(id)}` : ''}` diff --git a/src/modules/overseas-warehouse/components/DispatchAppointmentAllocationsDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchAppointmentAllocationsDialog.test.ts new file mode 100644 index 00000000..5ac2a8cd --- /dev/null +++ b/src/modules/overseas-warehouse/components/DispatchAppointmentAllocationsDialog.test.ts @@ -0,0 +1,86 @@ +import ElementPlus, { ElInputNumber } from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, expect, it, vi } from 'vitest' +import AppDialog from '@/shared/components/AppDialog.vue' +import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue' +import * as api from '../api/dispatches' +import type { AppointmentAllocations } from '../dispatchContracts' +import { command } from '../dispatchTestFixtures' + +const access = vi.hoisted(() => ({ can: true })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.can }) })) +vi.mock('../api/dispatches', () => ({ getDispatchAppointmentAllocations: vi.fn(), saveDispatchAppointmentAllocations: vi.fn() })) +function facts(): AppointmentAllocations { + return { dispatchNo: 'PC/1', recordVersion: '0', edit: { allowed: true, blockCode: null, blockReason: null }, items: ['11', '12'].map((id, index) => ({ + dispatchItemId: id, planPalletId: `${index + 21}`, customerId: '101', customerName: '客户甲', palletNo: `PALLET-${index + 1}`, allocations: [], + sources: [{ sourceKey: 'ORDER:1', orderNo: 'WC-1', inboundCode: 'IN-1', destinationKey: 'FBA:ACY2', packageCount: 10, blockReasons: [], + options: [{ batchKey: 'FBA_APPOINTMENT:9', batchNo: 'YY-9', recordVersion: '0', availablePackages: 20, confirmationNo: 'CONF-9', appointmentAt: '2026-10-26T10:00:00', appointmentEndAt: null, timezone: 'America/New_York' }] }], + })) } +} +async function open() { + const wrapper = mount(DispatchAppointmentAllocationsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) + await flushPromises(); return wrapper +} +beforeEach(() => { + vi.clearAllMocks(); access.can = true + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(facts()) + vi.mocked(api.saveDispatchAppointmentAllocations).mockResolvedValue(command('EDIT')) +}) +it('links both pallets to the confirmed appointment on the original dispatch with explicit version zero', async () => { + const wrapper = await open() + expect(wrapper.text()).toContain('WC-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).toContain('America/New_York') + expect(wrapper.text()).not.toContain('ORDER:1') + const dialog = wrapper.findComponent(AppDialog) + expect(dialog.props('confirmDisabled')).toBe(false) + dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises() + expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledTimes(1) + expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: '0', dispatchItemIds: ['11', '12'], + batchAllocations: [expect.objectContaining({ planPalletId: '21', packageCount: 10, recordVersion: '0' }), expect.objectContaining({ planPalletId: '22', packageCount: 10, recordVersion: '0' })] })) + expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() +}) +it('counts a shared source quota once across pallets and rejects incomplete allocation', async () => { + const value = facts(); value.items.forEach(item => { item.sources[0]!.options[0]!.availablePackages = 15 }) + vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value) + const wrapper = await open(); const dialog = wrapper.findComponent(AppDialog) + expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('本票剩余可排量不足') + wrapper.findAllComponents(ElInputNumber)[0]!.vm.$emit('update:modelValue', 5); await flushPromises() + expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('须完整分配 10 箱') + dialog.vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount() +}) +it('preserves edited quantities after rejection and retries with current versions and a new command key', async () => { + const value = facts(); value.items = value.items.slice(0, 1) + value.items[0]!.sources[0]!.options.push({ ...value.items[0]!.sources[0]!.options[0]!, batchKey: 'FBA_APPOINTMENT:10', batchNo: 'YY-10' }) + vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value) + const wrapper = await open(); const inputs = wrapper.findAllComponents(ElInputNumber) + inputs[0]!.vm.$emit('update:modelValue', 6); inputs[1]!.vm.$emit('update:modelValue', 4); await flushPromises() + const latest = structuredClone(value); latest.recordVersion = '2'; latest.items[0]!.sources[0]!.options.forEach(option => { option.recordVersion = '3' }) + vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('版本已变化')) + vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest) + wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() + expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([6, 4]) + expect(wrapper.emitted('saved')).toBeUndefined() + wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() + const calls = vi.mocked(api.saveDispatchAppointmentAllocations).mock.calls + expect(calls).toHaveLength(2); expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey) + expect(calls[1]![1]).toMatchObject({ recordVersion: '2', batchAllocations: [{ recordVersion: '3', packageCount: 6 }, { recordVersion: '3', packageCount: 4 }] }) + wrapper.unmount() +}) +it('retains an unavailable former choice after a failed save until the user clears it', async () => { + const wrapper = await open() + vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('预约已变化')) + const latest = facts(); latest.items[0]!.sources[0]!.options = [] + vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest) + wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() + expect(wrapper.text()).toContain('已失效'); expect(wrapper.findAllComponents(ElInputNumber)[0]!.props('modelValue')).toBe(10) + expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true); wrapper.unmount() +}) +it.each(['permission', 'gate', 'unconfirmed'] as const)('does not save when blocked by %s', async mode => { + const value = facts() + if (mode === 'permission') access.can = false + if (mode === 'gate') value.edit = { allowed: false, blockCode: 'NOT_ELIGIBLE', blockReason: '本车已有真实扫码' } + if (mode === 'unconfirmed') value.items[0]!.sources[0] = { ...value.items[0]!.sources[0]!, options: [], blockReasons: ['请先确认送仓时间'] } + vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value) + const wrapper = await open(); expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true) + wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/DispatchAppointmentAllocationsDialog.vue b/src/modules/overseas-warehouse/components/DispatchAppointmentAllocationsDialog.vue new file mode 100644 index 00000000..8d7867d3 --- /dev/null +++ b/src/modules/overseas-warehouse/components/DispatchAppointmentAllocationsDialog.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue b/src/modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue index 3003a4f3..0f5220f5 100644 --- a/src/modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue +++ b/src/modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue @@ -52,6 +52,7 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车'] +
客户订单号入仓码重量(kg)托数目的FBA仓体积(m³)集装箱号
{{ order.customerName || '—' }}{{ order.orderNo || '—' }}{{ order.inboundCode || '—' }}{{ order.grossWeightKg ?? '—' }}{{ order.palletCount ?? '—' }}{{ order.destinationFbaWarehouse || '—' }}{{ order.volumeCbm ?? '—' }}{{ order.containerNo || '—' }}
暂无关联订单
diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index 2342054d..7a89364a 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -17,6 +17,7 @@ import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchActi import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery' import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue' import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue' +import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue' import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance' import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue' @@ -32,6 +33,9 @@ const currentOperation = computed(() => pending.value ? pending.value.operation const route = ref(null), stops = ref([]) const detail = ref(null), working = ref(false), error = ref(null) const factsStale = ref(false) +const appointmentsOpen = ref(false) +const canLinkAppointments = computed(() => vehicleEditor.value && detail.value?.orderDetails.some(row => Boolean(row.destinationFbaWarehouse)) && canDispatchAction(detail.value?.actions, 'EDIT', access.can)) +async function appointmentsSaved() { error.value = null; await refreshFacts(); emit('saved') } const preview = ref(null) const loading = computed(() => working.value) const measurements = ref>([]) @@ -148,6 +152,7 @@ async function submitReview(decision: 'APPROVED' | 'REJECTED') { await submit() } async function submit(submitReviewAfterSave = false) { + if (appointmentsOpen.value) return if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return submitting.value = true error.value = null @@ -217,6 +222,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation], +
关联正式预约

卸货顺序

起运仓{{ route.stops[0]?.name || '—' }}地址{{ route.stops[0]?.address || '—' }}
@@ -231,6 +237,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],