From 8b02f1d8700a5c60328e5d2d5b3937f83451d250 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 21 Oct 2026 14:41:26 +0800
Subject: [PATCH] fix(appointment): make FBA confirmation missing cargo
actionable
---
.../AppointmentConfirmationCargo.test.ts | 98 +++++++++++++++++++
.../AppointmentConfirmationCargo.vue | 85 ++++++++++++++++
.../AppointmentCreatePanel.vue | 4 +-
.../CargoVerificationForm.vue | 17 ++--
.../WorkAppointmentForm.test.ts | 2 +-
.../WorkAppointmentForm.vue | 7 +-
.../WorkCreateAppointmentForm.vue | 7 +-
src/modules/appointment-calendar/index.ts | 2 +
.../components/FbaAppointmentFields.vue | 9 +-
.../pages/InboundDialogs.behavior.test.ts | 4 +-
.../InboundOrdersView.appointment.test.ts | 4 +-
...nboundOrdersView.confirmationCargo.test.ts | 46 +++++++++
.../pages/InboundOrdersView.vue | 32 ++++--
13 files changed, 290 insertions(+), 27 deletions(-)
create mode 100644 src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts
create mode 100644 src/modules/appointment-calendar/AppointmentConfirmationCargo.vue
create mode 100644 src/modules/overseas-warehouse/pages/InboundOrdersView.confirmationCargo.test.ts
diff --git a/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts b/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts
new file mode 100644
index 00000000..931cac25
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts
@@ -0,0 +1,98 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+import { ElMessageBox } from 'element-plus'
+import AppointmentConfirmationCargo from './AppointmentConfirmationCargo.vue'
+import { resolvePlanningOrder, resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
+import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
+
+const access = vi.hoisted(() => ({ read: true }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
+vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
+vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanningSource: vi.fn() }))
+vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
+const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
+const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
+const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
+const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
+function render(active = true, disabled = false) {
+ return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
+ ElButton: { props: ['disabled', 'nativeType'], template: '' },
+ InlineHelp: { template: '' }, ApiErrorMeta: true,
+ LoadErrorState: { props: ['message'], template: '
{{ message }}
' },
+ } } })
+}
+beforeEach(() => {
+ vi.clearAllMocks(); access.read = true
+ vi.mocked(resolvePlanningOrder).mockResolvedValue(resolved)
+ vi.mocked(resolvePlanningSource).mockResolvedValue(resolved)
+ vi.mocked(getCargoVerification).mockResolvedValue(context)
+ vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: source.sourceVersion, destinationKey: source.destinationKey, sourcePackages: 20, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试员', verifiedAt: '2026-10-21T03:00:00Z' })
+})
+it('names the missing material and supplements the canonical booking inside the appointment', async () => {
+ const wrapper = render(); await flushPromises()
+ expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
+ expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
+ expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
+ await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
+ expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
+ await wrapper.get('form').trigger('submit'); await flushPromises()
+ expect(saveCargoVerification).not.toHaveBeenCalled()
+ await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
+ vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
+ await wrapper.get('form').trigger('submit'); await flushPromises()
+ expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'], expectedSourceVersion: source.sourceVersion }))
+ expect(wrapper.text()).toContain('资料齐全,无需重复填写')
+ expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
+})
+it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
+ const wrapper = render(false); await flushPromises()
+ expect(resolvePlanningOrder).not.toHaveBeenCalled()
+ await wrapper.setProps({ active: true }); await flushPromises()
+ expect(resolvePlanningOrder).toHaveBeenCalledTimes(1); wrapper.unmount()
+})
+it('does not ask for known materials to be entered again', async () => {
+ vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
+ const wrapper = render(); await flushPromises()
+ expect(wrapper.text()).toContain('资料齐全,无需重复填写')
+ expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
+ expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
+})
+it('retains unsaved inputs when the appointment mode changes and blocks leaving an unknown save outcome', async () => {
+ const wrapper = render(); await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
+ await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
+ await wrapper.setProps({ active: false })
+ expect(wrapper.get('textarea[aria-label="本票货件号"]').isVisible()).toBe(true)
+ vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce(new Error('keep editing'))
+ expect(await wrapper.vm.canLeave()).toBe(false)
+ vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('connection lost'))
+ await wrapper.get('form').trigger('submit'); await flushPromises()
+ expect(await wrapper.vm.canLeave()).toBe(false)
+ const payload = vi.mocked(saveCargoVerification).mock.calls[0]![1]
+ await wrapper.get('form').trigger('submit'); await flushPromises()
+ expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, payload); wrapper.unmount()
+})
+it('reports unknown quantity and unmaterialized cargo without inventing values', async () => {
+ vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, source: { ...source, orderId: null } })
+ vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: null })
+ const wrapper = render(); await flushPromises()
+ expect(wrapper.text()).toContain('缺少本票箱数'); expect(wrapper.text()).toContain('原来货登记完成实收关联')
+ expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
+ expect(wrapper.emitted('ready')?.at(-1)).toEqual([false]); wrapper.unmount()
+})
+it('keeps failed reads blocked, supports retry, and denies reads without permission', async () => {
+ vi.mocked(resolvePlanningOrder).mockRejectedValueOnce(new Error('network lost'))
+ const wrapper = render(); await flushPromises()
+ expect(wrapper.find('[role=alert]').exists()).toBe(true)
+ expect(wrapper.emitted('ready')?.at(-1)).toEqual([false])
+ await wrapper.get('[role=alert] button').trigger('click'); await flushPromises()
+ expect(resolvePlanningOrder).toHaveBeenCalledTimes(2); wrapper.unmount()
+ vi.clearAllMocks(); access.read = false
+ const denied = render(); await flushPromises()
+ expect(resolvePlanningOrder).not.toHaveBeenCalled(); expect(denied.text()).toContain('查看权限'); denied.unmount()
+})
+it('keeps the supplement action disabled for read-only appointment views', async () => {
+ const wrapper = render(true, true); await flushPromises()
+ expect(wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.attributes('disabled')).toBeDefined()
+ expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
+})
diff --git a/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue b/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue
new file mode 100644
index 00000000..1e45c0db
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue
@@ -0,0 +1,85 @@
+
+
+
+ 正式确认前:核对本次送仓货物
+ 重新核对资料
+ 本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。
+
+ | 操作 | 入仓码 / 外接登记号 | 本票总箱数 | 需要补充的资料 |
+
+ | 在此补齐资料— |
+ {{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }} |
+ {{ row.cargo.effectivePackages ?? '待补充' }} |
+ {{ issues(row).join(';') || '资料齐全,无需重复填写' }} |
+
+
+
+
+ 收起补充资料
+
+ 请先在上表补齐并保存资料,再点击“保存预约安排”。暂时拿不到资料,可先选择“先保存安排,等待对方确认”。
+
+
diff --git a/src/modules/appointment-calendar/AppointmentCreatePanel.vue b/src/modules/appointment-calendar/AppointmentCreatePanel.vue
index b08d5eed..8da2ea1d 100644
--- a/src/modules/appointment-calendar/AppointmentCreatePanel.vue
+++ b/src/modules/appointment-calendar/AppointmentCreatePanel.vue
@@ -42,13 +42,13 @@ const columns: DataTableColumn[] = [
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 },
- { key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
+ { key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' },
]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) {
if (row.detained) return '货物暂扣中'
if (row.mixedDestinations) return '多个目的地,需先核对'
- if (row.hasActiveAppointment) return '已有有效预约'
+ if (row.hasActiveAppointment) return '已有预约安排,请到入库单编辑原预约'
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
diff --git a/src/modules/appointment-calendar/CargoVerificationForm.vue b/src/modules/appointment-calendar/CargoVerificationForm.vue
index 4c9f587d..5b02ec3d 100644
--- a/src/modules/appointment-calendar/CargoVerificationForm.vue
+++ b/src/modules/appointment-calendar/CargoVerificationForm.vue
@@ -10,15 +10,16 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
-const props = defineProps<{ source: CargoVerificationSource }>()
+const props = defineProps<{ source: CargoVerificationSource; confirmation?: boolean; disabled?: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const context = ref(null), loading = ref(false), saving = ref(false)
const error = ref(null), saveError = ref(null), pending = ref(null)
const form = reactive({ packages: '', references: '', remark: '' }), baseline = ref(JSON.stringify(form))
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
-useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
-const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
+const canLeave = useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
+defineExpose({ canLeave })
+const canWrite = computed(() => !props.disabled && access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
async function load(preserve = false) {
loading.value = true; error.value = null
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) { error.value = { message: '当前账号没有该货源的读取权限,请联系预约负责人处理。' }; loading.value = false; return }
@@ -26,7 +27,7 @@ async function load(preserve = false) {
context.value = await getCargoVerification(props.source)
if (!preserve) {
const record = context.value.verification
- Object.assign(form, { packages: record?.packageCount == null ? '' : String(record.packageCount), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
+ Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
baseline.value = JSON.stringify(form)
}
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
@@ -39,6 +40,7 @@ async function save() {
if (count !== null && (!Number.isSafeInteger(count) || count < 1)) { ElMessage.warning('核实箱数须为正整数,暂未核实可留空'); return }
if (count !== null && (count < context.value.allocatedPackages || (context.value.source.packages != null && count > context.value.source.packages))) { ElMessage.warning('核实箱数不能低于已分配量,也不能超过已知原箱数'); return }
const references = [...new Set(form.references.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
+ if (props.confirmation && (count === null || !references.length)) { ElMessage.warning('请填写本票箱数,以及至少一个 FBA 货件号或追踪号;外部约号不能代替货件号'); return }
if (references.length > 100 || references.some(value => value.length > 128)) { ElMessage.warning('最多填写100条货件号,每条不超过128字'); return }
pending.value = { destinationKey: context.value.source.destinationKey, expectedSourceVersion: context.value.source.sourceVersion, expectedVersion: Number(context.value.verification?.version || 0), packageCount: count, shipmentReferences: references, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() }
}
@@ -56,14 +58,15 @@ onMounted(() => load())
- 核实货物资料未约成也可保存;只用于预约核实,保留原订单资料。
+ {{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}未约成也可保存;只用于预约核实,保留原订单资料。
+ 请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。
正在读取货物资料…
- - 原票
- {{ context.source.inboundCode || context.source.bookingNo || '—' }}
- 目的仓
- {{ context.source.destinationWarehouseCode || '—' }}
- 原箱数
- {{ context.source.packages ?? '待核实' }}
- 已分配
- {{ context.allocatedPackages }} 箱;另有 {{ context.unknownAllocationCount }} 项数量未知
- 可分配
- {{ context.availablePackages ?? '待核实' }}
+ - 原票
- {{ context.source.inboundCode || context.source.bookingNo || '—' }}
- 目的仓
- {{ context.source.destinationWarehouseCode || '—' }}
- 原箱数
- {{ context.source.packages ?? '待核实' }}
- 已分配
- {{ context.allocatedPackages }} 箱;另有 {{ context.unknownAllocationCount }} 项数量未知
- 可分配
- {{ context.availablePackages ?? '待核实' }}
原货源已变化,请核对当前原票和已分配量后重新保存。
{{ reason }}
-
+
diff --git a/src/modules/appointment-calendar/index.ts b/src/modules/appointment-calendar/index.ts
index b6fce0af..fddb4e09 100644
--- a/src/modules/appointment-calendar/index.ts
+++ b/src/modules/appointment-calendar/index.ts
@@ -1,2 +1,4 @@
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
+export { default as AppointmentConfirmationCargo } from './AppointmentConfirmationCargo.vue'
+export type { PlanningSourceLookup } from './planningApi'
diff --git a/src/modules/overseas-warehouse/components/FbaAppointmentFields.vue b/src/modules/overseas-warehouse/components/FbaAppointmentFields.vue
index 5118e9d9..e2d361d5 100644
--- a/src/modules/overseas-warehouse/components/FbaAppointmentFields.vue
+++ b/src/modules/overseas-warehouse/components/FbaAppointmentFields.vue
@@ -1,9 +1,16 @@
+ 目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。
+
预约信息
| 系统单号 | {{ appointmentNo || '保存后生成' }} | 目的仓时区 | {{ timezone || '待核实' }} |
|---|
预约安排
diff --git a/src/modules/overseas-warehouse/pages/InboundDialogs.behavior.test.ts b/src/modules/overseas-warehouse/pages/InboundDialogs.behavior.test.ts
index 4f23f6a2..46c63f89 100644
--- a/src/modules/overseas-warehouse/pages/InboundDialogs.behavior.test.ts
+++ b/src/modules/overseas-warehouse/pages/InboundDialogs.behavior.test.ts
@@ -19,7 +19,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
-vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
+vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null, AppointmentConfirmationCargo: { name: 'AppointmentConfirmationCargo', mounted() { this.$emit('ready', true) }, methods: { canLeave: async () => true }, template: '' } }))
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '' } }))
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
@@ -99,7 +99,7 @@ describe('inbound appointment native dialogs', () => {
expect(dialog.text()).toContain('appointment-popup-request')
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
- for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
+ for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect(await (vi.mocked(hook).mock.calls[0]![0] as () => Promise)()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(updateFbaAppointment).mock.calls[1]).toEqual(original)
expect(dialog.props('modelValue')).toBe(false)
diff --git a/src/modules/overseas-warehouse/pages/InboundOrdersView.appointment.test.ts b/src/modules/overseas-warehouse/pages/InboundOrdersView.appointment.test.ts
index e72619d5..12f2ed04 100644
--- a/src/modules/overseas-warehouse/pages/InboundOrdersView.appointment.test.ts
+++ b/src/modules/overseas-warehouse/pages/InboundOrdersView.appointment.test.ts
@@ -42,11 +42,11 @@ it('keeps the inbound appointment retry across close, reopen and route attempts'
const done=vi.fn();dialog.props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
expect(wrapper.find('[data-save]').exists()).toBe(true)
await open();await flushPromises();expect(getFbaAppointment).toHaveBeenCalledTimes(1)
- for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
+ for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect(await (vi.mocked(hook).mock.calls[0]![0] as () => Promise)()).toBe(false)
await wrapper.get('[data-save]').trigger('click');await flushPromises()
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2,'AP-1',original)
expect(wrapper.find('[data-save]').exists()).toBe(false)
- expect((vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => boolean)()).toBe(true)
+ expect(await (vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => Promise)()).toBe(true)
wrapper.unmount()
})
it('renders MULTIPLE as multiple appointments and opens the exact source/customer instead of choosing an arbitrary appointment', async () => {
diff --git a/src/modules/overseas-warehouse/pages/InboundOrdersView.confirmationCargo.test.ts b/src/modules/overseas-warehouse/pages/InboundOrdersView.confirmationCargo.test.ts
new file mode 100644
index 00000000..72f3841d
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/InboundOrdersView.confirmationCargo.test.ts
@@ -0,0 +1,46 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+import InboundOrdersView from './InboundOrdersView.vue'
+import { getFbaAppointment, listInboundOrders, updateFbaAppointment, type FbaAppointmentDto } from '../api/inboundOrders'
+
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
+vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
+vi.mock('../api/inboundOrders', async original => ({ ...await original(), listInboundOrders: vi.fn(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
+const appointment: FbaAppointmentDto = { id: '1', appointmentNo: 'YY-TEST', version: 2, originWarehouseId: 'warehouse-1', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', timezone: 'America/Los_Angeles', items: [{ id: 'item-1', customerId: 'customer-1', sourceKey: 'BOOKING:BK-TEST', orderId: '9007199254740993', inboundCode: 'IN-TEST', fbaWarehouseCode: 'ONT8', packageCount: 20 }] }
+function render() {
+ return mount(InboundOrdersView, { global: { stubs: {
+ AdminPage: { template: '' }, ListPageLayout: { template: '
' },
+ DataTable: { props: ['data'], template: '
' },
+ AppDialog: { props: ['modelValue', 'showConfirm', 'confirmText'], template: '' },
+ FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'sources', 'disabled'], template: '' },
+ ElButton: { props: ['disabled'], template: '' },
+ SmartDispatchDialog: true, QueryToolbar: true, OperationErrorToast: true, LoadErrorState: true, ApiErrorMeta: true,
+ } } })
+}
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ lineId: 'line-1', planId: 'plan-1', planNo: 'PLAN-TEST', customerId: 'customer-1', orderId: '9007199254740993', overseasWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', packageCount: 20, status: 'COMPLETED', appointmentNo: appointment.appointmentNo, appointmentStatus: 'PENDING' }], total: 1, page: 1, size: 10 })
+ vi.mocked(getFbaAppointment).mockResolvedValue(appointment)
+ vi.mocked(updateFbaAppointment).mockResolvedValue({ ...appointment, version: 3, appointmentStatus: 'SUCCESS' })
+})
+it('uses all canonical appointment items and blocks formal submission until cargo is verified', async () => {
+ const wrapper = render(); await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises()
+ const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
+ expect(fields.props('sources')).toEqual([{ sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', destinationKey: 'FBA:ONT8' }])
+ Object.assign(fields.props('model'), { appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-TEST', appointmentAt: '2026-10-21T10:00:00' })
+ await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
+ expect(updateFbaAppointment).not.toHaveBeenCalled()
+ fields.vm.$emit('confirmationReady', true); await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
+ expect(updateFbaAppointment).toHaveBeenCalledWith('YY-TEST', expect.objectContaining({ version: 2, details: expect.objectContaining({ appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-TEST' }) }))
+ wrapper.unmount()
+})
+it('keeps cargo supplementation disabled when viewing an appointment', async () => {
+ const wrapper = render(); await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '查看预约')!.trigger('click'); await flushPromises()
+ expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true)
+ expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
+ expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
+})
diff --git a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
index 50773ee7..f8ba7164 100644
--- a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
+++ b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
@@ -17,6 +17,7 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
+import type { PlanningSourceLookup } from '@/modules/appointment-calendar'
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
@@ -59,6 +60,9 @@ const appointmentFbaWarehouse = ref('—')
const appointmentVersion = ref(0)
const appointmentForm = reactive(createAppointmentForm())
const appointmentTimezone = ref(null)
+const appointmentSources = ref([])
+const confirmationReady = ref(false)
+const appointmentFields = ref | null>(null)
const pendingAppointment = ref<{appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: ReturnType; idempotencyKey: string} | null>(null)
const appointmentViewOnly = ref(false)
const appointmentReplacementNos = ref([])
@@ -132,13 +136,15 @@ function selectionError() {
return ''
}
-function openAppointment() {
- if (!access.can(permission.inboundOrderAppointment) || !canLeaveAppointment()) return
+async function openAppointment() {
+ if (!access.can(permission.inboundOrderAppointment) || !await canLeaveAppointment()) return
appointmentViewOnly.value = false
appointmentReplacementNos.value = []
const error = selectionError()
if (error) { ElMessage.warning(error); return }
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
+ confirmationReady.value = false
+ appointmentSources.value = selectedRows.value.map(row => ({ orderId: row.orderId, customerId: row.customerId, destinationKey: `FBA:${row.fbaWarehouse}` }))
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
appointmentVersion.value = 0
Object.assign(appointmentForm, createAppointmentForm()); appointmentTimezone.value = null; pendingAppointment.value = null
@@ -152,7 +158,7 @@ function openSmartDispatch() {
}
async function openAppointmentEdit(source: object, readOnly = false) {
- if (!canLeaveAppointment()) return
+ if (!await canLeaveAppointment()) return
const row = source as InboundOrderListItem
if (row.appointmentStatus === '多批预约') { openMultipleAppointments(row); return }
if (!row.appointmentNo || row.appointmentNo === '—') return
@@ -166,24 +172,27 @@ async function openAppointmentEdit(source: object, readOnly = false) {
appointmentSelectedCount.value = detail.selectedCount
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
appointmentVersion.value = detail.version
+ confirmationReady.value = false
+ appointmentSources.value = detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))
Object.assign(appointmentForm, createAppointmentForm(detail)); appointmentTimezone.value = detail.timezone || null; pendingAppointment.value = null
} catch (caught) {
appointmentOpen.value = false
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
} finally { appointmentLoading.value = false }
}
-function openMultipleAppointments(row: InboundOrderListItem) {
- if (!canLeaveAppointment() || !access.can('platform:appointment:list')) return
+async function openMultipleAppointments(row: InboundOrderListItem) {
+ if (!await canLeaveAppointment() || !access.can('platform:appointment:list')) return
if (!row.orderId || !row.customerId || !row.fbaWarehouse || row.fbaWarehouse === '—') { ElMessage.warning('原货源或客户范围缺失,请重新读取入库单'); return }
void router.push({ path: '/appointment-calendar', query: { orderId: row.orderId, customerId: row.customerId, destinationKey: `FBA:${row.fbaWarehouse}` } })
}
-function canLeaveAppointment() {
+async function canLeaveAppointment() {
+ if (!appointmentOpen.value) return true
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
- return true
+ return await appointmentFields.value?.canLeave?.() ?? true
}
-function closeAppointment() { if (canLeaveAppointment()) appointmentOpen.value = false }
-function beforeCloseAppointment(done: () => void) { if (canLeaveAppointment()) done() }
+async function closeAppointment() { if (await canLeaveAppointment()) appointmentOpen.value = false }
+async function beforeCloseAppointment(done: () => void) { if (await canLeaveAppointment()) { appointmentOpen.value = false; done() } }
onBeforeRouteLeave(canLeaveAppointment)
onBeforeRouteUpdate(canLeaveAppointment)
async function submitAppointment() {
@@ -191,6 +200,9 @@ async function submitAppointment() {
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
const validation = appointmentFormError(appointmentForm)
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
+ if (!pendingAppointment.value && appointmentForm.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料,再保存预约安排'); return }
+ if (!pendingAppointment.value && appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
+ if (saving.value || appointmentReadOnly.value) return
saving.value = true; mutationError.value = null
try {
pendingAppointment.value ||= {appointmentNo: appointmentMode.value === 'edit' ? editingAppointmentNo.value : null, version: appointmentVersion.value, originWarehouseId: selectedRows.value[0]?.originWarehouseId || '', fbaWarehouseCode: selectedRows.value[0]?.fbaWarehouse || '', lineIds: selectedRows.value.map(row => row.lineId), details: appointmentPayload(appointmentForm), idempotencyKey: crypto.randomUUID()}
@@ -247,7 +259,7 @@ onMounted(() => { void load() })
预约范围
| 已选择 | {{ appointmentSelectedCount }} 条 | FBA仓库 | {{ appointmentFbaWarehouse }} |
|---|
-
+