diff --git a/docs/business-index.md b/docs/business-index.md index dd0ebd80..ad53b656 100644 --- a/docs/business-index.md +++ b/docs/business-index.md @@ -37,7 +37,7 @@ | 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | -| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | +| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后预计到港经 `/estimated-arrival` 按编辑权限和版本单独维护,其余运输/预报/关系方/柜型/附件只读;客服实货量经 `/actual-cargo` 按编辑权限、版本、幂等键和原因补录;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,托运单取委值,提单确认件取手工实值,未填显示“—”且不以委值回退。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | | 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | | 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts index 13399974..dd57c44e 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -8,11 +8,11 @@ const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events }, global: { stubs: { ElButton: { template: '' } } } }) function reportedDispatch() { - const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] } + const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] } return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events } describe('four-type appointment calendar', () => { - it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => { + it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 ', 'D1821', ' d1821 ', 'D1822', '1821'])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => { const wrapper = render(reportedDispatch(), '2026-10-23') const search = wrapper.get('[aria-label="搜索当前日历"]') expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库') @@ -26,6 +26,7 @@ describe('four-type appointment calendar', () => { expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001') expect(wrapper.get('.detail-event').text()).toContain('09:00') expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321') + expect(wrapper.get('.detail-event').text()).toContain('D1821、D1822') expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00') expect(wrapper.emitted('range')).toBeUndefined() wrapper.unmount() diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index 438815cf..d850c778 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -14,10 +14,10 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() })) vi.mock('./tripCalendar', async original => ({ ...await original(), listTripCalendar: vi.fn() })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) -const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true } +const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, plannedDate: '2026-10-21', isDefault: true, actualAt: null, version: 0, planCount: 0, canEdit: true } const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true } function dispatchEntry(overrides: Partial = {}): TripCalendarEntry { - return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides } + return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides } } export function renderSchedule() { return mount(AppointmentCalendarView, { global: { stubs: { @@ -34,7 +34,7 @@ export function renderSchedule() { } } }) } beforeEach(() => { - vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear() + vi.resetAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear() vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }) vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse]) vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] }) @@ -110,29 +110,24 @@ describe('simplified appointment entry', () => { expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0) wrapper.unmount() }) - it('retries an unknown save with the same payload and key, then only reloads reads', async () => { + it('releases an unknown save and sends the edited date with a fresh key', async () => { vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({}) const wrapper = renderSchedule(); await flushPromises() wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises() - await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30') - await wrapper.get('[data-save]').trigger('click'); await flushPromises() - expect(wrapper.text()).toContain('提交结果尚未确认') - expect(wrapper.get('input').attributes('disabled')).toBeDefined() - const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise - expect(await guard()).toBe(false) - await wrapper.get('[data-save]').trigger('click'); await flushPromises() - expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0]) - expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String)) - expect(wrapper.find('[data-dialog]').exists()).toBe(false) - wrapper.unmount() + await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises() + expect(wrapper.get('input').attributes('disabled')).toBeUndefined() + await wrapper.get('input[data-temporal-picker]').setValue('2026-10-23T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises() + const calls = vi.mocked(saveWarehouseArrival).mock.calls + expect(calls[1]![2]).not.toBe(calls[0]![2]); expect(calls[1]![1].estimatedWarehouseArrivalAt).toBe('2026-10-23T09:30:00') + expect(wrapper.find('[data-dialog]').exists()).toBe(false); wrapper.unmount() }) - it('requires an explicit reread after a conflict and retains the entered date', async () => { + it('allows editing after a conflict and preserves input across an optional manual refresh', async () => { vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409)) const wrapper = renderSchedule(); await flushPromises() wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises() await wrapper.get('input').setValue('2026-10-22T09:30') await wrapper.get('[data-save]').trigger('click'); await flushPromises() - expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-save]').attributes('disabled')).toBeUndefined() vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }]) await wrapper.get('[data-retry]').trigger('click'); await flushPromises() expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30') diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index 3b5f74eb..18b2f614 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -87,30 +87,27 @@ async function openSource(event: ScheduleEvent) { await router.push(appointmentExternalTarget(event.sourcePath, context)) } const editor = ref(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false) -const saveError = ref(null), unknownResult = ref(false), conflict = ref(false) -let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null +const saveError = ref(null), conflict = ref(false) function edit(event: ScheduleEvent) { if (!event.canEdit || !event.warehouse) return editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value - pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false + saveError.value = null; conflict.value = false } async function save() { const row = editor.value?.warehouse - if (!row || saving.value || conflict.value || !plannedAt.value) return - pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version }, + if (!row || saving.value || !plannedAt.value) return + const request = { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version }, key: `calendar-arrival-${crypto.randomUUID()}` } saving.value = true; saveError.value = null try { - await saveWarehouseArrival(pending.id, pending.body, pending.key) - pending = null; unknownResult.value = false; editor.value = null + await saveWarehouseArrival(request.id, request.body, request.key) + editor.value = null ElMessage.success('计划到仓已保存') await load() } catch (cause) { saveError.value = getApiErrorDetails(cause, '计划到仓保存失败') - unknownResult.value = !rejectedCommand(cause) const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined conflict.value = status === 409 || /409$/.test(saveError.value.code || '') - if (!unknownResult.value) pending = null } finally { saving.value = false } } async function rereadEditor() { @@ -128,7 +125,7 @@ async function rereadEditor() { const guards = new Set() provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) }) async function canLeave() { - if (saving.value || unknownResult.value) return false + if (saving.value) return false for (const guard of guards) if (!await guard()) return false if (editor.value && plannedAt.value !== originalAt.value) { try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm admin-workbench is-dialog' }) } @@ -179,18 +176,17 @@ onBeforeUnmount(() => { ++generation; controller?.abort() }) - +
业务{{ editor.title }}
- -
+ +
-

上次提交结果尚未确认,请使用原请求重试,避免重复修改。

重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。

diff --git a/src/modules/appointment-calendar/AppointmentCargoPanel.vue b/src/modules/appointment-calendar/AppointmentCargoPanel.vue index 824bf14b..dc19e68a 100644 --- a/src/modules/appointment-calendar/AppointmentCargoPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCargoPanel.vue @@ -82,9 +82,9 @@ async function saveArrangement() { pendingCreate.value = null; batchPreview.value = null; selected.value = {} ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load() } catch (cause) { - createError.value = getApiErrorDetails(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 } + } finally { pendingCreate.value = null; creating.value = false } } let followupRequest = 0 const query = ref({ keyword: '' }) @@ -202,7 +202,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller - +

{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}

{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱

货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。

diff --git a/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts b/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts index f58855c8..06fc98b6 100644 --- a/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts +++ b/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts @@ -78,24 +78,17 @@ describe('appointment impact handling', () => { expect(wrapper.text()).toContain('认领跟进') expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount() }) - it('freezes an uncertain command and retries exactly once with original version and key', async () => { + it('unlocks close and navigation after uncertainty and permits a fresh click', async () => { vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 }) - vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}}) const wrapper = render(); await flushPromises() await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises() const original = vi.mocked(handleImpact).mock.calls[0]![1] - expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' }) - await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined() - const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled() - expect(guards.register.mock.calls[0]![0]()).toBe(false) - await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled() - for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false) - await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises() - expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original) - expect(wrapper.emitted('saved')).toHaveLength(1) - expect(getImpact).toHaveBeenCalledTimes(2) expect(guards.register.mock.calls[0]![0]()).toBe(true) - wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce() + await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toHaveLength(1) + const done = vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).toHaveBeenCalledOnce() + await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises() + expect(vi.mocked(handleImpact).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey) + expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('does not resolve without an actual handling result', async () => { const wrapper = render(); await flushPromises() diff --git a/src/modules/appointment-calendar/AppointmentImpactDialog.vue b/src/modules/appointment-calendar/AppointmentImpactDialog.vue index ad759f27..d7b1df19 100644 --- a/src/modules/appointment-calendar/AppointmentImpactDialog.vue +++ b/src/modules/appointment-calendar/AppointmentImpactDialog.vue @@ -26,8 +26,8 @@ async function save(action: ImpactCommand['action']) { pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null } saving.value = true; error.value = null try { await handleImpact(props.containerId, pending.value); pending.value = null; emit('saved'); historyPage.value = 1; await load() } - catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null } - finally { saving.value = false } + catch (cause) { error.value = getApiErrorDetails(cause, '操作失败,请重新操作'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null } + finally { pending.value = null; saving.value = false } } function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true } function close() { if (canLeave()) emit('close') } @@ -65,7 +65,7 @@ onMounted(load) 认领跟进 记录处理 完成本次核对 - 重试本次处理 +
观察及处理历史({{ detail.history.total }} 条)

{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC
{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}

diff --git a/src/modules/appointment-calendar/WorkDispatchForm.test.ts b/src/modules/appointment-calendar/WorkDispatchForm.test.ts index 214f242d..d1b7de30 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.test.ts +++ b/src/modules/appointment-calendar/WorkDispatchForm.test.ts @@ -1,7 +1,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import WorkDispatchForm from './WorkDispatchForm.vue' -import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse' +import { createDispatch, getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse' import { ApiResponseError, http } from '@/shared/api/http' import { parseApiJsonResponse } from '@chongchong/shared' import { getReceivingContext, type ReceivingContext } from './receivingApi' @@ -12,7 +12,7 @@ const identity = vi.hoisted(() => ({ id: '' })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) })) vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })) vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) -vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() })) +vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original(), createDispatch: vi.fn(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() })) vi.mock('./receivingApi', async original => ({ ...await original(), getReceivingContext: vi.fn() })) const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T const batch = 'FBA_APPOINTMENT:1' @@ -21,7 +21,7 @@ function row(id = '9007199254740993', customerId = 'customer-a', key = batch): B function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } } function detail(no = 'DS/001', version = 1): DispatchDetail { const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } - return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } + return { publishLooseboardDemand: false, dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], exceptions: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } } function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } } let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft @@ -33,7 +33,7 @@ it.each([0, '0', '9007199254740993'])('preserves authoritative allocation versio const wrapper = render() try { await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() - expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion) + expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations[0]!.recordVersion).toBe(recordVersion) } finally { wrapper.unmount() } }) it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => { @@ -49,6 +49,7 @@ beforeEach(() => { vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key)) vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] })) vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' })) + vi.mocked(createDispatch).mockImplementation(async body => proof(body.idempotencyKey)) vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft)) vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) }) vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) }) @@ -64,8 +65,8 @@ it('creates the whole dispatch when native Long candidate quantities arrive as s try { await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined() await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() - expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations) - expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1) + expect(createDispatch).toHaveBeenCalledOnce(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations) + expect(vi.mocked(createDispatch).mock.calls[0]![0].cargo[0]!.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1) } finally { wrapper.unmount(); get.mockRestore() } }) it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => { @@ -74,7 +75,7 @@ it('restores existing batch dispatches after remount and navigates to the same w const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING') await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount() } - expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled() + expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(createDispatch).not.toHaveBeenCalled() }) it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => { vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount() @@ -84,71 +85,52 @@ it('does not fall back to new creation when transport relations fail or their ne it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => { vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }])) const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled() - await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount() + await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(createDispatch).not.toHaveBeenCalled(); wrapper.unmount() }) -it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => { +it('creates the whole dispatch with native source versions and exact batch allocations', async () => { const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() - expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) }) - expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 }) - expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection }) - const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0] - expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] }) + expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ publishLooseboardDemand: false, warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null })) expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true) await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount() }) it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => { const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] }) const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true]) - await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups + await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = [{ dispatch: vi.mocked(createDispatch).mock.calls[0]![0] }] expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount() }) it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => { const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second)) vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] })) const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true) - await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount() + await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount() }) it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => { const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5') wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value))) vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] }) const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false]) - expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount() + expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations); wrapper.unmount() }) it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => { vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount() natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount() vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value }) - const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount() + const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(createDispatch).not.toHaveBeenCalled(); cross.unmount() }) -it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => { - vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response')) - const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0]) - expect(await guard()).toBe(false); wrapper.unmount() - const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises() - expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount() -}) -it('retries the exact container command after a lost response instead of generating another container key', async () => { - vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container')) - const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0]) - await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount() -}) -it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => { + + +it('shows the confirmed dispatch link even when its detail read fails', async () => { vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable')) - const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined() - await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount() -}) -it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => { - vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)) - const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount() - sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 } - const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9 - await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount() + const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() + expect(createDispatch).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toHaveLength(1) + await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); expect(await guard()).toBe(true); wrapper.unmount() }) + it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => { access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises() await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled() - await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1) + await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(createDispatch).toHaveBeenCalledTimes(1) await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount() }) it('does not read scoped cargo without original permissions or create while only read access remains', async () => { @@ -158,7 +140,7 @@ it('does not read scoped cargo without original permissions or create while only it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => { vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] }) const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount() - const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount() + const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(createDispatch).not.toHaveBeenCalled(); duplicate.unmount() }) it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => { @@ -176,8 +158,8 @@ it('keeps original batch allocations and selected cargo while paging through ass expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1) expect(listDispatchCandidates).toHaveBeenCalledTimes(2) await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() - expect(convertDispatchDraft).toHaveBeenCalledTimes(1) - expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations]) + expect(createDispatch).toHaveBeenCalledTimes(1) + expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...first.allocations, ...second.allocations]) } finally { wrapper.unmount() } }) it('rejects a selected batch source whose group version changed on another candidate page', async () => { @@ -195,3 +177,13 @@ it('rejects a selected batch source whose group version changed on another candi expect(createDispatchDraft).not.toHaveBeenCalled() } finally { wrapper.unmount() } }) + +it('failure preserves selection, releases navigation and creates a fresh request on the next click', async () => { + vi.mocked(createDispatch).mockRejectedValueOnce(new Error('timeout')) + const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() + const original = vi.mocked(createDispatch).mock.calls[0]![0] + expect(wrapper.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true) + expect(await guard()).toBe(true); expect(sessionStorage.length).toBe(0) + await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() + expect(vi.mocked(createDispatch).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey); expect(getReceivingContext).toHaveBeenCalledTimes(1); wrapper.unmount() +}) diff --git a/src/modules/appointment-calendar/WorkDispatchForm.vue b/src/modules/appointment-calendar/WorkDispatchForm.vue index 5a6f43d1..e78f7757 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.vue +++ b/src/modules/appointment-calendar/WorkDispatchForm.vue @@ -6,7 +6,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { ElMessage } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' -import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse' +import { getDispatch, createDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, dispatchVehicleSpecs, DispatchInputError, requireBatchVersions, expandSelectionGroups, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchDetail, type Candidate } from '@/modules/overseas-warehouse' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' @@ -17,11 +17,8 @@ import { useWorkDraft } from './useWorkDraft' const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>() const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>() type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] } -type ReadyRow = BatchRow & { native: Candidate } -type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record; attempted: boolean; rejected: boolean } -type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean } const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || '' -const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}` +const scope = JSON.stringify([...new Set(props.batchKeys)].sort()) const contexts = ref([]), rows = ref([]), selected = ref([]), warehouseId = ref('') const working = ref(false), saving = ref(false), error = ref(null), saveError = ref(null) const candidatePage = useDispatchCandidates(error, listDispatchCandidates) @@ -30,26 +27,24 @@ const loading = computed(() => working.value || candidatePage.loading.value) const selectedRows = ref>({}) let receivingContexts: Awaited>[] = [] let nativeGeneration = 0 -const capacity = ref(30), result = ref(null), pending = ref(null), corrupted = ref(false) +const createdNo = ref('') +const capacity = ref(30), result = ref(null) const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false) const existing = ref>([]), baseline = ref('[]') const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission))) const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create')) -const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value)) +const writable = computed(() => props.canWrite && canCreate.value && (!existing.value.length || existingReady.value)) const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row))) -const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value) -useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved) +const unresolved = computed(() => saving.value) +useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !createdNo.value), unresolved) const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T let generation = 0 function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') } -function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) } -try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation } -catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } } function reasons(row: BatchRow) { return row.blockReasons } function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size } function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` } async function load() { - if (!readable.value || pending.value || corrupted.value) return + if (!readable.value) return const current = ++generation, batches = [...new Set(props.batchKeys)] working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++ try { @@ -122,105 +117,24 @@ function select(row: BatchRow, event: Event) { } input.checked = selected.value.includes(row.key) } -function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record, attempted: false, rejected: false }; persist() } -function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } } -async function executeStep() { - const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted - command.attempted = true; persist() - try { - let value: DispatchDraft | ConvertedDraft - if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string }) - else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number }) - else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection) - else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string }) - else value = await convertDispatchDraft(command.body as unknown as ConvertDraft) - ensureOwner() - if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求') - if (command.action === 'CONVERT') { - const converted = value as ConvertedDraft - if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致') - validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey } }) - request.converted = converted - } else { - const draft = value as DispatchDraft; requireVersion(draft.recordVersion) - if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致') - if (command.action === 'CONTAINER') { - if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对') - request.containerId = draft.containers[0]!.draftContainerId - } - if (command.action === 'LOCK') { - const selectedSource = command.body.source as DraftSelection['source'] - const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion) - const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId) - if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求') - const group = requireSelectionGroup(source) - assertSelectionGroupVersions([expected!.native], [source]) - const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys - if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求') - request.lockedKeys.push(...group.sources.map(required => required.sourceKey)) - } - request.draft = draft - } - request.step = null; persist() - } catch (cause) { - ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true - persist(); throw cause - } -} async function save() { - if (saving.value || loading.value || corrupted.value || !canCreate.value) return - saveError.value = null + if (saving.value || loading.value || !canCreate.value) return + saveError.value = null; saving.value = true try { ensureOwner() - if (!pending.value) { - if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位') - candidatePage.assertCurrent(chosen.value.map(row => row.native!)) - pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist() - } - saving.value = true - const request = pending.value - while (!request.converted) { - if (!request.step) { - if (request.releasing) { step('RELEASE', common(request.draft!)) } - else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId }) - else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity }) - else { - const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey)) - if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection }) - else { - const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key)) - if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对') - step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { publishLooseboardDemand: true, idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft) - } - } - } - await executeStep() - if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return } - } - // Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts. - const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo) - ensureOwner(); validateCurrentDispatch(latest, proof) - result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]' - ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated') - } catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') } + if (!writable.value || error.value || createdNo.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位') + candidatePage.assertCurrent(chosen.value.map(row => row.native!)) + const body = { publishLooseboardDemand: false, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) } + const proof = await createDispatch(body); ensureOwner() + validateCommandResult(proof, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: body.idempotencyKey } }) + createdNo.value = proof.dispatchNo; selected.value = []; baseline.value = '[]'; emit('updated') + const latest = await getDispatch(proof.dispatchNo); ensureOwner(); validateCurrentDispatch(latest, proof) + result.value = latest; selected.value = []; baseline.value = '[]' + ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`) + } catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车创建失败,请刷新核对当前货物后重新操作') } finally { saving.value = false } } -async function releaseRejected() { - if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return - saving.value = true; saveError.value = null - try { - ensureOwner(); const request = pending.value - if (!request.draft) { pending.value = null; persist(); return } - const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken) - ensureOwner() - if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对') - request.draft = owned; request.releasing = true; request.step = null; persist() - } catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') } - finally { saving.value = false } - if (pending.value?.releasing) await save() - else if (!pending.value) await load() -} -async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } } +async function remaining() { if (!unresolved.value && !addingBusy.value) { createdNo.value = ''; result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } } onMounted(load) onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() }) @@ -230,12 +144,12 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()

读取批次实物需要预约、尾程订单、入仓单和排车列表权限。

正在核对批次、客户、起运仓与实物来源…

- diff --git a/src/modules/appointment-calendar/tripCalendar.test.ts b/src/modules/appointment-calendar/tripCalendar.test.ts index 75c0bb4b..4debfccd 100644 --- a/src/modules/appointment-calendar/tripCalendar.test.ts +++ b/src/modules/appointment-calendar/tripCalendar.test.ts @@ -4,15 +4,16 @@ import { http } from '@/shared/api/http' vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data })) const range = { from: '2026-10-01', to: '2026-11-01' } function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry { - return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] } + return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] } } describe('location-based trip calendar', () => { it('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => { - const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'] } + const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'] } const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range) expect(projected.events).toHaveLength(1) expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' }) expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' }) + expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜次号', value: 'D1821、D1822' }) expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' }) expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' }) const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) diff --git a/src/modules/appointment-calendar/tripCalendar.ts b/src/modules/appointment-calendar/tripCalendar.ts index 55419687..09be7bfc 100644 --- a/src/modules/appointment-calendar/tripCalendar.ts +++ b/src/modules/appointment-calendar/tripCalendar.ts @@ -5,6 +5,7 @@ export interface TripCalendarEntry { sourceType: 'DISPATCH' dispatchNo: string containerNumbers: string[] + cabinetNumbers: string[] status: string originWarehouseId: string | null originWarehouseCode: string | null @@ -81,6 +82,7 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s fields: [ { label: '运输状态', value: labels[row.status] || '待核实' }, { label: '关联柜号', value: row.containerNumbers.join('、') }, + { label: '关联柜次号', value: row.cabinetNumbers.join('、') }, { label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode }, { label: '地点时区', value: row.originTimezone }, { label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null }, diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 9e42d246..9538db6a 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { http } from '@/shared/api/http' +import { ApiResponseError, http } from '@/shared/api/http' import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms' -import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms' +import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, downloadContainerCustomsFinal, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms' const customs = { aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n, @@ -99,7 +99,42 @@ describe('platform container customs API', () => { expect(get.mock.calls).toEqual([ [`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }], - [`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }], + [`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }], ]) }) + + it.each([downloadContainerCustomsDocument, downloadContainerCustomsReceipt, downloadContainerCustomsFinal])('拒绝 HTTP 200 中的真实 JSON Blob 业务错误 %#', async (download) => { + const body = new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })], { type: 'application/json;charset=UTF-8' }) + vi.spyOn(http, 'get').mockResolvedValue(body) + + const error: unknown = await download('2092438843630391407').catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ code: 'A0404', message: '预录单不存在', status: 200, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' }) + }) + + it('非 2xx 的 JSON Blob 也保留业务错误和诊断信息', async () => { + const body = new Blob([JSON.stringify({ code: 'A0403', message: '无权下载预录单', data: null, requestId: 'request-forbidden', traceId: 'trace-forbidden' })], { type: 'application/json' }) + vi.spyOn(http, 'get').mockRejectedValue({ isAxiosError: true, response: { status: 403, data: body } }) + + await expect(downloadContainerCustomsReceipt('7')).rejects.toMatchObject({ code: 'A0403', message: '无权下载预录单', status: 403, requestId: 'request-forbidden', traceId: 'trace-forbidden' }) + }) + + it.each([ + ['application/pdf', '%PDF-1.7 原文件'], + ['application/octet-stream', '原始二进制'], + ['application/json', '{"product":"报关资料","code":"ITEM-1"}'], + ['application/json', '{invalid-json'], + ])('保留原文件内容与对象:%s', async (type, contents) => { + const body = new Blob([contents], { type }) + vi.spyOn(http, 'get').mockResolvedValue(body) + + await expect(downloadContainerCustomsFinal('7')).resolves.toBe(body) + }) + + it('非 JSON 的传输错误沿用原始错误', async () => { + const error = { isAxiosError: true, response: { status: 502, data: new Blob(['bad gateway'], { type: 'text/html' }) } } + vi.spyOn(http, 'get').mockRejectedValue(error) + + await expect(downloadContainerCustomsReceipt('7')).rejects.toBe(error) + }) }) diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index 24fc787f..43279d19 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -1,4 +1,5 @@ -import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import axios from 'axios' +import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED' export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION' @@ -210,16 +211,45 @@ export async function getContainerCustoms(loadingPlanId: string) { return value ? normalize(value) : null } +// 报关资料允许下载原始 JSON 文件,仅把统一业务错误信封识别为失败。 +async function customsDownloadError(blob: Blob, status: number): Promise { + if (!blob.type.toLowerCase().includes('json')) return null + let payload: unknown + try { payload = JSON.parse(await blob.text()) } catch { return null } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null + const envelope = payload as Record + if (typeof envelope.code !== 'string' || envelope.code === '200' + || typeof envelope.message !== 'string' || !('data' in envelope)) return null + return new ApiResponseError(envelope.message, envelope.code, status, envelope.data, + typeof envelope.requestId === 'string' ? envelope.requestId : undefined, + typeof envelope.traceId === 'string' ? envelope.traceId : undefined) +} + +async function downloadCustomsFile(path: string) { + try { + const blob = await http.get(path, { responseType: 'blob' }) + const error = await customsDownloadError(blob, 200) + if (error) throw error + return blob + } catch (error) { + if (axios.isAxiosError(error) && error.response?.data instanceof Blob) { + const downloadError = await customsDownloadError(error.response.data, error.response.status) + if (downloadError) throw downloadError + } + throw error + } +} + export function downloadContainerCustomsDocument(documentId: string) { - return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' }) + return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/download`) } export function downloadContainerCustomsReceipt(documentId: string) { - return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' }) + return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`) } export function downloadContainerCustomsFinal(documentId: string) { - return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' }) + return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`) } async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) { diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index ce80b16a..23b30c49 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import ElementPlus, { ElMessageBox } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' -import { ApiResponseError } from '@/shared/api/http' +import { ApiResponseError, http } from '@/shared/api/http' import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms' const api = vi.hoisted(() => ({ @@ -94,6 +94,8 @@ describe('ContainerCustomsPanel', () => { global: { plugins: [ElementPlus] }, }) await flushPromises() + expect(wrapper.text()).toContain('ENTRY-001') + expect(wrapper.text()).not.toContain('版本') await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click') const state = (wrapper.vm.$ as unknown as { setupState: { clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean @@ -114,12 +116,12 @@ describe('ContainerCustomsPanel', () => { wrapper.unmount() }) - it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => { + it('开始报关确认使用简短文案,取消不会冻结资料或调用开始接口', async () => { const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel') const wrapper = await render('READY_TO_START') await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click') await flushPromises() - expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' })) + expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' })) expect(api.start).not.toHaveBeenCalled() confirm.mockRestore() wrapper.unmount() @@ -167,11 +169,11 @@ describe('ContainerCustomsPanel', () => { expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false) expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('客户退税资料') - expect(wrapper.text()).toContain('报关行回执') + expect(wrapper.text()).toContain('预录单') expect(wrapper.text()).not.toContain('客户报关资料') expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true) - expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') - expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执') + expect(buttonTexts(wrapper)).not.toContain('上传预录单') + expect(buttonTexts(wrapper)).not.toContain('覆盖预录单') expect(buttonTexts(wrapper)).not.toContain('提交') }) @@ -204,13 +206,13 @@ describe('ContainerCustomsPanel', () => { it('allows completion upload and overwrite only while DECLARING with manage permission', async () => { let wrapper = await render('DECLARING', true, false) expect(wrapper.text()).toContain('首次发送报关行') - expect(wrapper.text()).toContain('首次发送后上传回执') - expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') + expect(wrapper.text()).toContain('首次发送后上传预录单') + expect(buttonTexts(wrapper)).not.toContain('上传预录单') wrapper.unmount() wrapper = await render('DECLARING', true, true) expect(wrapper.text()).toContain('发送报关行执行') - expect(wrapper.text()).toContain('覆盖报关行回执') + expect(wrapper.text()).toContain('覆盖预录单') expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).toContain('真实报关完税文件.pdf') expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一') @@ -218,8 +220,8 @@ describe('ContainerCustomsPanel', () => { wrapper = await render('DECLARING', false, true) expect(wrapper.text()).toContain('只读') - expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') - expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执') + expect(buttonTexts(wrapper)).not.toContain('上传预录单') + expect(buttonTexts(wrapper)).not.toContain('覆盖预录单') expect(buttonTexts(wrapper)).not.toContain('提交') }) @@ -235,14 +237,40 @@ describe('ContainerCustomsPanel', () => { wrapper.unmount() }) + it('隐藏资料版本但保留预录单历史、原文件名和上传并发版本', async () => { + const value = customs('DECLARING', true) + const document = value.documents[0]! + document.receipt!.version = 7 + document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }] + api.get.mockResolvedValue(value) + const wrapper = mount(ContainerCustomsPanel, { + props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' }, + global: { plugins: [ElementPlus] }, + }) + await flushPromises() + expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '客户退税资料', '预录单', '真实报关完税文件', '关联入仓码', '报关行状态', '客户', '预录单情况']) + expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/) + expect(wrapper.text()).toContain('预录单历史:2026/8/29 10:50:00 · 报关员') + expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一') + expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true) + const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' }) + const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void + onChange({ raw: selected }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click') + await flushPromises() + expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7) + wrapper.unmount() + }) + it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => { const wrapper = await render('COMPLETED', true, true) expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).toContain('真实报关完税文件.pdf') expect(wrapper.text()).toContain('报关已完成,待其他离仓条件') - expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') - expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执') + expect(buttonTexts(wrapper)).not.toContain('上传预录单') + expect(buttonTexts(wrapper)).not.toContain('覆盖预录单') expect(buttonTexts(wrapper)).not.toContain('提交') }) @@ -253,7 +281,7 @@ describe('ContainerCustomsPanel', () => { expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407') expect(api.downloadReceipt).not.toHaveBeenCalled() - await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click') + await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click') await flushPromises() expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407') await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click') @@ -277,4 +305,46 @@ describe('ContainerCustomsPanel', () => { expect(wrapper.text()).toContain('trace-customs-download') expect(api.downloadReceipt).not.toHaveBeenCalled() }) + + it('下载预录单失败时透传正式名称与诊断信息,保留原文件名', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-pre-entry', traceId: 'trace-pre-entry' })], { type: 'application/json' })) + api.downloadReceipt.mockImplementationOnce(async (documentId: string) => { + const actual = await vi.importActual('../api/platformContainerCustoms') + return actual.downloadContainerCustomsReceipt(documentId) + }) + const wrapper = await render('COMPLETED', false, true) + + await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click') + await flushPromises() + + await vi.waitFor(() => expect(wrapper.text()).toContain('预录单不存在')) + expect(wrapper.text()).toContain('request-pre-entry') + expect(wrapper.text()).toContain('trace-pre-entry') + expect(wrapper.text()).toContain('报关行回执.pdf') + expect(wrapper.text()).not.toContain('报关行回执不存在') + expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled() + expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/download', { responseType: 'blob' }) + get.mockRestore() + wrapper.unmount() + }) + + it('上传冲突时展示服务端预录单提示并保留待上传文件', async () => { + const message = '客户确认的预录单已发送给报关行,不可再修改预录单' + api.upload.mockRejectedValueOnce(new ApiResponseError(message, 'A0409', 200, undefined, 'request-pre-entry-upload', 'trace-pre-entry-upload')) + const wrapper = await render('DECLARING', true, true) + const selected = new File(['receipt'], '预录单.pdf', { type: 'application/pdf' }) + const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void + onChange({ raw: selected }) + await flushPromises() + const submit = wrapper.findAll('button').find(button => button.text() === '提交')! + await submit.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain(message) + expect(wrapper.text()).toContain('A0409') + expect(wrapper.text()).toContain('request-pre-entry-upload') + expect(wrapper.text()).toContain('trace-pre-entry-upload') + expect(submit.attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) }) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 46549a02..58905c02 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -79,9 +79,6 @@ const statusText: Record = { function documentCustomers(document: ContainerCustomsDocument) { return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—' } -function receiptHistoryLabel(document: ContainerCustomsDocument) { - return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—' -} function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) { return action === 'CONFIRM' ? '确认' : '打回' } @@ -126,7 +123,7 @@ function declarationStatusLabel(value: DeclarationBatch['status']) { async function createFormalDeclaration() { if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return - try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return } + try { await ElMessageBox.confirm('当前柜的资料、预录单已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return } actionLoading.value = true; actionError.value = null try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') } catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') } @@ -183,7 +180,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB async function start() { if (!customs.value || customs.value.status !== 'READY_TO_START') return try { - await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' }) + await ElMessageBox.confirm('确认开始整柜报送?', '整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认' }) } catch { return } actionLoading.value = true; actionError.value = null try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') } @@ -194,15 +191,15 @@ async function start() { async function uploadReceipt(document: ContainerCustomsDocument) { if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return const selected = receiptFiles[document.documentId] - if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return } + if (!selected) { ElMessage.warning('请选择对应的预录单'); return } actionLoading.value = true; actionError.value = null try { await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0) receiptFiles[document.documentId] = null await load() emit('refreshed') - ElMessage.success('报关行回执已上传,等待客户确认') - } catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') } + ElMessage.success('预录单已上传,等待客户确认') + } catch (error) { actionError.value = getApiErrorDetails(error, '预录单上传失败') } finally { actionLoading.value = false } } @@ -239,9 +236,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | ? await downloadContainerCustomsDocument(document.documentId) : kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId) saveBlob(blob, target.fileName) - ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`) + ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载已开始`) } catch (error) { - actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`) + actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载失败`) } finally { downloadingKey.value = '' } @@ -249,7 +246,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | async function submitToBroker() { if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return - try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return } + try { await ElMessageBox.confirm('确认已核对全部预录单并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return } actionLoading.value = true; actionError.value = null try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') } catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') } @@ -258,9 +255,9 @@ async function submitToBroker() { async function sendToBroker() { if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return - try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return } + try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回预录单,关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return } actionLoading.value = true; actionError.value = null - try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') } + try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待预录单') } catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') } finally { actionLoading.value = false } } @@ -315,7 +312,7 @@ onMounted(load)