import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import { ElMessageBox } from 'element-plus' import { createAppointmentForm, appointmentPayload } from '@/modules/overseas-warehouse' import { ApiResponseError } from '@/shared/api/http' import WorkReceivingPanel from './WorkReceivingPanel.vue' import { batchContext } from './batch.test-support' import * as api from './receivingApi' import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi' import type { LeaveGuard } from './useWorkDraft' const access = vi.hoisted(() => ({ denied: [] as string[] })) vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner-current', name: '当前本人' } }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !access.denied.includes(permission) }) })) vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } })) vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() })) vi.mock('./receivingApi', async original => ({ ...await original(), getReceivingContext: vi.fn(), getReceivingGroup: vi.fn(), proposeReceivingChange: vi.fn(), resolveReceivingChange: vi.fn(), bookReceiving: vi.fn(), changeReceivingGroup: vi.fn(), refreshReceivingCargo: vi.fn(), saveReceivingPlan: vi.fn(), getReceivingExecutionScope: vi.fn() })) let guard: LeaveGuard const detail = () => appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-OLD', appointmentAt: '2026-10-20T10:00:00' }) function context(): api.ReceivingContext { const batch = batchContext().appointment batch.items = batch.items.map(item => ({ ...item, sourceVersion: 'source-v3', cargoVerificationVersion: 2 })) return { batch, executionScope: { version: 'execution-v1', members: batch.items.map(item => ({ member: { batchKey: batch.batchKey!, customerId: item.customerId }, completed: false, canRebook: true, reason: null })) }, agreements: [], pendingChanges: [], plans: [], allowedActions: ['PROPOSE_CUSTOMER_CHANGE', 'BOOK_RECEIVING', 'PLAN_RECEIVING', 'REFRESH_CARGO'], blockReasons: [], transport: { version: 'transport-v1', vehicles: [], blockReasons: [] } } } function members(): api.ReceivingMember[] { const c = context(); return c.batch.items.map(item => ({ batchKey: c.batch.batchKey!, appointmentNo: c.batch.appointmentNo, batchVersion: c.batch.version, customerId: item.customerId, customerName: null, sources: [{ sourceKey: item.sourceKey, inboundCode: item.inboundCode || null, destinationKey: item.destinationKey!, packageCount: item.packageCount!, sourceVersion: item.sourceVersion!, verificationVersion: item.cargoVerificationVersion! }] })) } function agreement(status = 'ACTIVE'): api.ReceivingAgreement { return { agreementKey: 'GROUP-1', version: 4, status, destinationKey: 'FBA:ONT8', details: detail(), timezone: 'America/Los_Angeles', members: members(), warehouseEvidence: '原仓库确认' } } function pendingChange(): api.ReceivingPending { return { changeKey: 'CHANGE-1', version: 1, status: 'WAITING', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }], laterDate: null, ownerId: 'owner-related', nextFollowupDate: '2026-10-21', reason: '本客户后送', transportVersion: 'transport-v1' }, originalMembers: [members()[0]!], originalAgreements: [agreement()], followup: { ownerId: 'owner-related', ownerName: '已核实负责人', nextFollowupDate: '2026-10-21' } } } function result(status = 'APPLIED'): api.ReceivingResult { return { changeKey: null, status, batches: [context().batch], agreements: [], steps: [{ step: 'BOOK_RECEIVING', status: 'SUCCESS', targetKey: 'GROUP-1', message: '已保存真实预约' }], nextActions: ['REVIEW_TRANSPORT'] } } function render() { return mount(WorkReceivingPanel, { props: { batchKey: 'FBA_APPOINTMENT:1', canWrite: true, owners: [{ id: 'owner-related', name: '关联负责人' }] }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub, ElButton: { props: ['disabled', 'nativeType'], template: '' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '
' }, LoadErrorState: { props: ['message', 'showRetry'], template: '
{{ message }}
' }, ApiErrorMeta: true, } } }) } function button(wrapper: ReturnType, label: string) { return wrapper.findAll('button').find(row => row.text() === label)! } beforeEach(() => { vi.clearAllMocks(); access.denied = []; vi.mocked(api.getReceivingExecutionScope).mockImplementation(async members => ({ version: 'execution-v1', members: members.map(member => ({ member, completed: false, canRebook: true, reason: null })) })); vi.mocked(api.getReceivingContext).mockResolvedValue(context()); vi.mocked(api.proposeReceivingChange).mockResolvedValue(result('WAITING')); vi.mocked(api.resolveReceivingChange).mockResolvedValue(result()); vi.mocked(api.bookReceiving).mockResolvedValue(result()); vi.mocked(api.refreshReceivingCargo).mockResolvedValue({ ...result(), steps: [], nextActions: [] }); vi.mocked(api.saveReceivingPlan).mockResolvedValue({ ...result(), steps: [], nextActions: [] }); vi.mocked(api.changeReceivingGroup).mockResolvedValue(result()); vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: agreement(), eligibleMembers: members(), excludedMembers: [], transport: context().transport, executionScope: context().executionScope }) }) async function prepareAdjustment(wrapper: ReturnType, quantity = 0) { await flushPromises(); await button(wrapper, '调整本客户先送 / 后送').trigger('click'); await flushPromises() await wrapper.get('[aria-label="本次先送箱数"]').setValue(quantity) await wrapper.get('[aria-label="调整负责人"]').setValue('owner-related') await wrapper.get('[aria-label="调整原因"]').setValue('本客户后送') await wrapper.get('[aria-label="确认当前范围"]').setValue(true) } it('allows zero retained quantity for one customer and only persists a waiting change with all original versions', async () => { const wrapper = render(); await prepareAdjustment(wrapper) await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.proposeReceivingChange).toHaveBeenCalledWith(expect.objectContaining({ intent: expect.objectContaining({ batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', ownerId: 'owner-related', laterDate: null, transportVersion: 'transport-v1', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }] }) })) expect(wrapper.text()).toContain('原安排尚未改变'); expect(api.resolveReceivingChange).not.toHaveBeenCalled(); expect(api.bookReceiving).not.toHaveBeenCalled() expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount() }) it('rejects unchanged, fractional or excessive retained quantities without creating empty later batches', async () => { const wrapper = render(); await prepareAdjustment(wrapper, 70) await wrapper.get('form').trigger('submit'); await flushPromises(); expect(wrapper.text()).toContain('本次先送量未改变') for (const quantity of [71, -1, 0.5]) { await wrapper.get('[aria-label="本次先送箱数"]').setValue(quantity); await wrapper.get('form').trigger('submit'); await flushPromises() } expect(api.proposeReceivingChange).not.toHaveBeenCalled(); wrapper.unmount() }) it('locks unknown outcomes and retries the identical change command without applying or recreating cargo', async () => { vi.mocked(api.proposeReceivingChange).mockRejectedValueOnce(new Error('network timeout')) const wrapper = render(); await prepareAdjustment(wrapper, 30); await wrapper.get('form').trigger('submit'); await flushPromises() const first = vi.mocked(api.proposeReceivingChange).mock.calls[0]![0] expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined() await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.proposeReceivingChange).toHaveBeenNthCalledWith(2, first); expect(api.resolveReceivingChange).not.toHaveBeenCalled(); wrapper.unmount() }) it('resumes persisted waiting input, requires warehouse evidence and sends the current change and transport versions', async () => { const c = context(); c.pendingChanges = [pendingChange()]; c.transport.version = 'transport-v2'; c.allowedActions = [] vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises(); await button(wrapper, '继续本次变化').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('本次先送 0 箱'); expect(wrapper.text()).toContain('EXT-OLD') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.resolveReceivingChange).not.toHaveBeenCalled() await wrapper.get('[aria-label="仓库回复依据"]').setValue('仓库同意该客户全部后送,其他客户确认保留') await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.resolveReceivingChange).toHaveBeenCalledWith('CHANGE-1', expect.objectContaining({ decision: 'CONFIRM', version: 1, transportVersion: 'transport-v2' })) expect(api.proposeReceivingChange).not.toHaveBeenCalled(); wrapper.unmount() }) it('withdraws only the persisted change without claiming a cancellation or requiring confirmation evidence', async () => { const c = context(); c.pendingChanges = [pendingChange()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c) vi.mocked(api.resolveReceivingChange).mockResolvedValue(result('WITHDRAWN')) const wrapper = render(); await flushPromises(); await button(wrapper, '继续本次变化').trigger('click'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await button(wrapper, '撤回变化并保留原安排').trigger('click'); await flushPromises() expect(api.resolveReceivingChange).toHaveBeenCalledWith('CHANGE-1', expect.objectContaining({ decision: 'WITHDRAW', warehouseEvidence: null })) expect(wrapper.text()).toContain('已撤回变化,原安排保留'); wrapper.unmount() }) it('rebooks only eligible cancelled-group members and submits one shared booking with a new command identity', async () => { const c = context(); c.agreements = [agreement('CANCELLED')]; vi.mocked(api.getReceivingContext).mockResolvedValue(c) vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: [members()[0]!], excludedMembers: [{ member: members()[1]!, reason: '已另行预约', currentAgreementKey: 'GROUP-OTHER' }], transport: c.transport, executionScope: c.executionScope }) const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('已另行预约'); expect(wrapper.text()).not.toContain('GROUP-OTHER') Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'EXT-NEW', appointmentAt: '2026-10-23T11:00:00' }) await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仓库确认当前剩余客户货量'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() const request = vi.mocked(api.bookReceiving).mock.calls[0]![0] expect(request.cancelledAgreementKey).toBe('GROUP-1'); expect(request.cancelledAgreementVersion).toBe(4) expect(request.bookings).toHaveLength(1); expect(request.bookings[0]!.members).toEqual([{ batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a' }]) expect(request.bookings[0]!.details.confirmationNo).toBe('EXT-NEW'); wrapper.unmount() }) it('preserves per-customer booking input on an old-number conflict and requires renewed scope confirmation', async () => { vi.mocked(api.bookReceiving).mockRejectedValueOnce(new ApiResponseError('当前目的仓约号已使用', 'A0409', 409)) const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises() const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model') Object.assign(model, { confirmationNo: 'EXT-IN-USE', appointmentAt: '2026-10-23T11:00:00' }) await wrapper.get('[aria-label="预约覆盖依据"]').setValue('明确覆盖'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() expect(model.confirmationNo).toBe('EXT-IN-USE'); expect(wrapper.text()).toContain('当前目的仓约号已使用') expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined(); expect(wrapper.emitted('updated')).toBeUndefined() wrapper.unmount() }) it('requires explicit whole-scope cancellation confirmation and uses the group version and delete permission', async () => { const c = context(); c.agreements = [agreement()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c) access.denied = ['platform:overseas-warehouse:inbound-order:fba-appointment'] const wrapper = render(); await flushPromises(); await button(wrapper, '核对整份取消范围').trigger('click'); await flushPromises() await wrapper.get('[aria-label="整份回复依据"]').setValue('仓库确认整份取消'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() expect(ElMessageBox.confirm).toHaveBeenCalled() expect(api.changeReceivingGroup).toHaveBeenCalledWith('GROUP-1', expect.objectContaining({ action: 'CANCEL', version: 4, details: null, transportVersion: 'transport-v1' })); wrapper.unmount() }) it('keeps a successful result when refresh fails and retries only reads', async () => { const wrapper = render(); await prepareAdjustment(wrapper) vi.mocked(api.getReceivingContext).mockRejectedValueOnce(new Error('读取暂不可用')) await wrapper.get('form').trigger('submit'); await flushPromises() expect(wrapper.text()).toContain('原安排尚未改变'); expect(wrapper.find('form').exists()).toBe(false) await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises() expect(api.proposeReceivingChange).toHaveBeenCalledTimes(1); wrapper.unmount() }) it('does not expose internal customer IDs or replace unknown cargo with zero or permit read-only writes', async () => { const c = context(); c.batch.items[0]!.packageCount = null; vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises() expect(wrapper.text()).not.toContain('customer-a'); expect(wrapper.text()).toContain('箱数待核实') await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('货物数量或来源资料待核实'); expect(wrapper.find('[data-booking-fields]').exists()).toBe(false) expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined() await wrapper.setProps({ canWrite: false }); await wrapper.get('form').trigger('submit'); expect(api.bookReceiving).not.toHaveBeenCalled(); wrapper.unmount() }) it('sends independent confirmations as distinct bookings while preserving original batch identities', async () => { const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises() await wrapper.findAll('label').find(label => label.text().includes('所选客户分别登记预约'))!.get('input').setValue(true) await flushPromises() wrapper.findAllComponents({ name: 'FbaAppointmentFields' }).forEach((fields, index) => Object.assign(fields.props('model'), { confirmationNo: `EXT-${index}`, appointmentAt: `2026-10-${22 + index}T10:00:00` })) for (const textarea of wrapper.findAll('[aria-label="预约覆盖依据"]')) await textarea.setValue('本客户仓库明确确认') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() const request = vi.mocked(api.bookReceiving).mock.calls[0]![0] expect(request.bookings.map(row => row.members)).toEqual(members().map(({ batchKey, batchVersion, customerId }) => [{ batchKey, batchVersion, customerId }])) expect(request.bookings.map(row => row.details.confirmationNo)).toEqual(['EXT-0', 'EXT-1']) expect(request.cancelledAgreementKey).toBeNull(); wrapper.unmount() }) it('does not apply existing booking inputs to different members after a range conflict', async () => { const original = context(); original.agreements = [agreement('CANCELLED')] vi.mocked(api.getReceivingContext).mockResolvedValue(original) vi.mocked(api.getReceivingGroup).mockResolvedValueOnce({ agreement: original.agreements[0]!, eligibleMembers: members(), excludedMembers: [], transport: original.transport, executionScope: original.executionScope }).mockResolvedValue({ agreement: original.agreements[0]!, eligibleMembers: [members()[1]!], excludedMembers: [{ member: members()[0]!, reason: '已另行预约', currentAgreementKey: 'OTHER' }], transport: original.transport, executionScope: original.executionScope }) vi.mocked(api.bookReceiving).mockRejectedValueOnce(new ApiResponseError('范围版本变化', 'A0409', 409)) const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises() Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'OLD-DRAFT', appointmentAt: '2026-10-22T10:00:00' }) await wrapper.get('[aria-label="预约覆盖依据"]').setValue('原范围依据'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() expect(wrapper.text()).toContain('原输入不会自动套用给其他客户') expect(wrapper.find('[data-booking-fields]').exists()).toBe(false) expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined() expect(api.bookReceiving).toHaveBeenCalledTimes(1); wrapper.unmount() }) it('treats unchanged whole-group rescheduling as unchanged rather than a new successful mutation', async () => { const c = context(); c.agreements = [agreement()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c) vi.mocked(api.changeReceivingGroup).mockResolvedValue({ ...result('UNCHANGED'), steps: [] }) const wrapper = render(); await flushPromises(); await button(wrapper, '核对整份改期范围').trigger('click'); await flushPromises() expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo).toBe('EXT-OLD') await wrapper.get('[aria-label="整份回复依据"]').setValue('核对无变化'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.changeReceivingGroup).toHaveBeenCalledWith('GROUP-1', expect.objectContaining({ action: 'RESCHEDULE', details: detail() })) expect(wrapper.text()).toContain('安排没有变化'); expect(wrapper.text()).not.toContain('已保存真实预约'); wrapper.unmount() }) it('uses only the eligible B/D cross-batch transport scope after A booked separately', async () => { const a = { ...members()[0]!, batchKey: 'FBA_APPOINTMENT:10', appointmentNo: 'AP-A' } const b = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:20', appointmentNo: 'AP-B' } const d = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:30', appointmentNo: 'AP-D', customerId: 'customer-d' } const cancelled = { ...agreement('CANCELLED'), members: [a, b, d] } const c = context(); c.agreements = [cancelled]; c.transport.version = 'old-A-B-D' vi.mocked(api.getReceivingContext).mockResolvedValue(c) vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: cancelled, eligibleMembers: [b, d], excludedMembers: [{ member: a, reason: '已另行预约', currentAgreementKey: 'OTHER-A' }], transport: { version: 'current-B-D', vehicles: [], blockReasons: ['无关客户已完成运输'] }, executionScope: { version: 'execution-B-D', members: [b, d].map(member => ({ member, completed: false, canRebook: true, reason: null })) } }) const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises() Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'NEW-B-D', appointmentAt: '2026-10-22T10:00:00' }) await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅覆盖B/D'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() const request = vi.mocked(api.bookReceiving).mock.calls[0]![0] expect(api.getReceivingExecutionScope).toHaveBeenCalledWith([{ batchKey: b.batchKey, customerId: b.customerId }, { batchKey: d.batchKey, customerId: d.customerId }]); expect(request.executionVersion).toBe('execution-v1'); expect(request).not.toHaveProperty('transportVersion') expect(request.bookings[0]!.members.map(member => member.batchKey)).toEqual(['FBA_APPOINTMENT:20', 'FBA_APPOINTMENT:30']) expect(request.bookings[0]!.members.some(member => member.customerId === a.customerId)).toBe(false); wrapper.unmount() }) it('uses verified owner candidates and renders authoritative pending followup names instead of account IDs', async () => { const c = context(); c.pendingChanges = [pendingChange()] vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises() expect(wrapper.text()).toContain('负责人 已核实负责人'); expect(wrapper.text()).not.toContain('owner-related') await button(wrapper, '调整本客户先送 / 后送').trigger('click'); await flushPromises() const owner = wrapper.get('[aria-label="调整负责人"]') expect(owner.element.tagName).toBe('SELECT') expect(owner.findAll('option').map(option => option.text())).toEqual(['请选择负责人', '关联负责人', '当前本人']) await wrapper.get('[aria-label="本次先送箱数"]').setValue(0) await owner.setValue('not-an-account'); await wrapper.get('[aria-label="调整原因"]').setValue('后送') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择有效负责人') await owner.setValue('owner-current'); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.proposeReceivingChange).toHaveBeenCalledWith(expect.objectContaining({ intent: expect.objectContaining({ ownerId: 'owner-current' }) })) expect(vi.mocked(api.proposeReceivingChange).mock.calls[0]![0].intent).not.toHaveProperty('owner') wrapper.unmount() }) it('books unfinished B using execution eligibility even when completed A blocks the shared transport and lacks cargo details', async () => { const c = context() c.executionScope.members[0] = { member: c.executionScope.members[0]!.member, completed: true, canRebook: false, reason: '本客户已完成,不再重约' } c.batch.items[0]!.packageCount = null c.transport.blockReasons = ['已完成客户禁止调整车辆'] vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('本客户已完成,不再重约'); expect(wrapper.text()).not.toContain('货物数量或来源资料待核实') Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'ONLY-B', appointmentAt: '2026-10-23T11:00:00' }) await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅当前未完成客户'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() const command = vi.mocked(api.bookReceiving).mock.calls[0]![0] expect(command.executionVersion).toBe('execution-v1'); expect(command).not.toHaveProperty('transportVersion') expect(command.bookings[0]!.members.map(member => member.customerId)).toEqual(['customer-b']) wrapper.unmount() }) it('does not offer members missing execution eligibility or marked completed even if canRebook is true', async () => { const c = context(); c.executionScope.members = [{ ...c.executionScope.members[0]!, completed: true, canRebook: true }] vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises() expect(wrapper.find('[data-booking-fields]').exists()).toBe(false) await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined() wrapper.unmount() }) function mixedPlans(): api.ReceivingContext { const c = context() c.batch.plannedDate = '2026-10-20'; c.batch.confirmationNo = 'A-CONFIRMED'; c.batch.appointmentStatus = 'SUCCESS' c.agreements = [{ ...agreement(), members: [members()[0]!] }] c.batch.items.push({ ...c.batch.items[1]!, id: 'item-d', customerId: 'customer-d', sourceKey: 'ORDER:D', inboundCode: 'IN-D' }) c.executionScope.members.push({ member: { batchKey: c.batch.batchKey!, customerId: 'customer-d' }, completed: false, canRebook: true, reason: null }) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }] return c } async function enterBPlan(wrapper: ReturnType) { await flushPromises() const buttons = wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成') expect(buttons[0]!.attributes('disabled')).toBeDefined() await buttons[1]!.trigger('click'); await flushPromises() } it('saves only B pending plan in the same stable batch where A is independently confirmed and D is still unbooked', async () => { const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await enterBPlan(wrapper) expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-24') expect(wrapper.findAll('[aria-label="客户计划状态"] option').map(row => row.attributes('value'))).toEqual(['PENDING', 'FAILED']) await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-26'); await wrapper.get('[aria-label="客户计划备注"]').setValue('乙等待回复') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ member: { batchKey: c.batch.batchKey, batchVersion: 3, customerId: 'customer-b' }, executionVersion: 'execution-v1', details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: '2026-10-26', appointmentRemark: '乙等待回复', confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) })) expect(api.bookReceiving).not.toHaveBeenCalled(); expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(api.changeReceivingGroup).not.toHaveBeenCalled() expect(c.agreements[0]!.details.confirmationNo).toBe('EXT-OLD'); expect(wrapper.emitted('updated')).toHaveLength(1) wrapper.unmount() }) it('requires a failed-plan reason and allows an explicitly cleared date without inheriting A or the old header date', async () => { const c = mixedPlans(); c.plans[0]!.details.plannedDate = null; vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await enterBPlan(wrapper) expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('') await wrapper.get('[aria-label="客户计划状态"]').setValue('FAILED'); await wrapper.get('[aria-label="客户计划备注"]').setValue('') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写未约成的原因') await wrapper.get('[aria-label="客户计划备注"]').setValue('仓库暂未提供时段'); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentStatus: 'FAILED', appointmentRemark: '仓库暂未提供时段', confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) })) wrapper.unmount() }) it('keeps customer plan drafts after a version conflict and resubmits explicitly with current batch and execution versions', async () => { const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c) vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new ApiResponseError('批次版本变化', 'A0409', 409)) const wrapper = render(); await enterBPlan(wrapper) await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-28'); await wrapper.get('[aria-label="客户计划备注"]').setValue('保留乙的输入') const fresh = structuredClone(c); fresh.batch.version = 4; fresh.executionScope.version = 'execution-v2'; fresh.plans[0]!.details.plannedDate = '2026-10-25' vi.mocked(api.getReceivingContext).mockResolvedValue(fresh) await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() const first = vi.mocked(api.saveReceivingPlan).mock.calls[0]![0] expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-28') expect(button(wrapper, '保存本客户计划').attributes('disabled')).toBeDefined() await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() const second = vi.mocked(api.saveReceivingPlan).mock.calls[1]![0] expect(second.details).toEqual(first.details); expect(second.member.batchVersion).toBe(4); expect(second.executionVersion).toBe('execution-v2'); expect(second.idempotencyKey).not.toBe(first.idempotencyKey) wrapper.unmount() }) it('retries the identical unknown customer plan and shows unchanged without creating a confirmation', async () => { vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()) vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new Error('timeout')).mockResolvedValueOnce({ ...result('UNCHANGED'), steps: [], nextActions: [] }) const wrapper = render(); await enterBPlan(wrapper) await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() const first = vi.mocked(api.saveReceivingPlan).mock.calls[0]![0] expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined() await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).toHaveBeenNthCalledWith(2, first); expect(wrapper.text()).toContain('安排没有变化'); expect(api.bookReceiving).not.toHaveBeenCalled() wrapper.unmount() }) it('blocks plans for confirmed, waiting-change, completed and unauthorized customers', async () => { const c = mixedPlans(); c.pendingChanges = [{ ...pendingChange(), intent: { ...pendingChange().intent, customerId: 'customer-b' } }] c.executionScope.members.find(row => row.member.customerId === 'customer-d')!.completed = true vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises() expect(wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成').every(row => row.attributes('disabled') !== undefined)).toBe(true) wrapper.unmount() vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()) const readonly = render(); await enterBPlan(readonly); await readonly.setProps({ canWrite: false }) await readonly.get('[aria-label="确认当前范围"]').setValue(true); await readonly.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).not.toHaveBeenCalled(); readonly.unmount() }) it('starts D plan empty without copying confirmed A or another customer plan and clears an existing B date explicitly', async () => { const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises() await wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成')[2]!.trigger('click'); await flushPromises() expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('') await button(wrapper, '返回范围概览').trigger('click'); await flushPromises(); await enterBPlan(wrapper) await wrapper.get('[aria-label="客户计划日期"]').setValue('') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ member: expect.objectContaining({ customerId: 'customer-b' }), details: expect.objectContaining({ plannedDate: null }) })) wrapper.unmount() }) it('preserves cross-day planned windows and explicit DST offsets in the customer plan without confirmation fields', async () => { vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()) const wrapper = render(); await enterBPlan(wrapper) await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-31') await wrapper.get('[aria-label="客户计划开始"]').setValue('2026-10-31T23:30:00') await wrapper.get('[aria-label="客户计划结束"]').setValue('2026-11-01T01:30:00') await wrapper.get('[aria-label="客户计划开始时差"]').setValue('-07:00') await wrapper.get('[aria-label="客户计划结束时差"]').setValue('-08:00') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: '2026-10-31', plannedAt: expect.stringContaining('2026-10-31T23:30'), plannedEndAt: expect.stringContaining('2026-11-01T01:30'), startOffset: '-07:00', endOffset: '-08:00', appointmentAt: null, appointmentEndAt: null }) })) wrapper.unmount() }) it('blocks dated plans without authoritative timezone but permits undated failure notes without a browser timezone', async () => { const c = mixedPlans(); c.plans[0]!.timezone = null vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await enterBPlan(wrapper) await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('目的仓时区待核实,请先补齐') await wrapper.get('[aria-label="客户计划日期"]').setValue(''); await wrapper.get('[aria-label="客户计划状态"]').setValue('FAILED') await wrapper.get('[aria-label="客户计划备注"]').setValue('待核实仓库地点') await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: null, appointmentStatus: 'FAILED', startOffset: null, endOffset: null }) })) wrapper.unmount() }) it('preserves a DST-rejected plan for correction and never copies it into a formal booking', async () => { vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()) vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new ApiResponseError('当地时刻存在歧义,请明确时差', 'A0400', 400)) const wrapper = render(); await enterBPlan(wrapper) await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-11-01'); await wrapper.get('[aria-label="客户计划开始"]').setValue('2026-11-01T01:30:00') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(wrapper.text()).toContain('当地时刻存在歧义') expect((wrapper.get('[aria-label="客户计划开始"]').element as HTMLInputElement).value).toContain('2026-11-01T01:30') expect(button(wrapper, '保存本客户计划').attributes('disabled')).toBeDefined() await wrapper.get('[aria-label="客户计划开始时差"]').setValue('-08:00'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).toHaveBeenCalledTimes(2); expect(api.bookReceiving).not.toHaveBeenCalled() wrapper.unmount() }) it('inherits unconfirmed legacy header plans only without active independent coverage and strips stale confirmation fields', async () => { for (const status of ['PENDING', 'FAILED'] as const) { const c = context(); Object.assign(c.batch, { timezone: 'America/Los_Angeles', plannedDate: '2026-10-27', plannedAt: '2026-10-27T10:00:00', appointmentStatus: status, appointmentRemark: '原联系原因', confirmationNo: 'STALE', appointmentAt: '2026-10-20T09:00:00' }) vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises() expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-27') expect((wrapper.get('[aria-label="客户计划备注"]').element as HTMLTextAreaElement).value).toBe('原联系原因') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.saveReceivingPlan).toHaveBeenLastCalledWith(expect.objectContaining({ details: expect.objectContaining({ appointmentStatus: status, plannedDate: '2026-10-27', confirmationNo: null, appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: null }) })) wrapper.unmount() } }) it('never inherits cancelled targets and keeps an explicit empty customer plan ahead of a pending header', async () => { for (const ownPlan of [false, true]) { const c = context(); Object.assign(c.batch, { appointmentStatus: ownPlan ? 'PENDING' : 'CANCELLED', plannedDate: '2026-10-27', appointmentRemark: '旧批原因' }) if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload(createAppointmentForm()) }] vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises() expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('') expect((wrapper.get('[aria-label="客户计划备注"]').element as HTMLTextAreaElement).value).toBe('') wrapper.unmount() } }) it('selects only A for independent rebooking from a cancelled group sharing one stable batch with B and D', async () => { const c = mixedPlans(); c.agreements = [{ ...agreement('CANCELLED'), members: [...members(), { ...members()[1]!, customerId: 'customer-d', customerName: '丁' }] }] vi.mocked(api.getReceivingContext).mockResolvedValue(c) vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: c.agreements[0]!.members, excludedMembers: [], transport: c.transport, executionScope: c.executionScope }) const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises() await wrapper.get('[aria-label="选择预约客户 2"]').setValue(false); await wrapper.get('[aria-label="选择预约客户 3"]').setValue(false) Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'A-ONLY', appointmentAt: '2026-10-24T10:00:00' }) await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅甲取得确认'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() const request = vi.mocked(api.bookReceiving).mock.calls[0]![0] expect(request.bookings).toHaveLength(1); expect(request.bookings[0]!.members).toEqual([{ batchKey: c.batch.batchKey!, batchVersion: 3, customerId: 'customer-a' }]) expect(request.executionVersion).toBe(c.executionScope.version); expect(request.cancelledAgreementKey).toBe('GROUP-1') wrapper.unmount() }) it('keeps independent booking fields matched to the selected customer and blocks an empty selection', async () => { const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises() await wrapper.findAll('label').find(label => label.text().includes('所选客户分别登记预约'))!.get('input').setValue(true) wrapper.findAllComponents({ name: 'FbaAppointmentFields' }).forEach((fields, index) => Object.assign(fields.props('model'), { confirmationNo: `FOR-${index}`, appointmentAt: '2026-10-24T10:00:00' })) await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false) expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo).toBe('FOR-1') await wrapper.get('[aria-label="选择预约客户 2"]').setValue(false) expect(wrapper.get('[aria-label="确认当前范围"]').attributes('disabled')).toBeDefined(); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.bookReceiving).not.toHaveBeenCalled() await wrapper.get('[aria-label="选择预约客户 2"]').setValue(true); await flushPromises(); await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅乙确认') await wrapper.get('form').trigger('submit'); await flushPromises() expect(vi.mocked(api.bookReceiving).mock.calls[0]![0].bookings[0]).toEqual(expect.objectContaining({ members: [expect.objectContaining({ customerId: 'customer-b' })], details: expect.objectContaining({ confirmationNo: 'FOR-1' }) })) wrapper.unmount() }) it('reads an exact cross-batch subset execution token without changing the visible batch or cancelled-group versions', async () => { const c = context(); const a = members()[0]!, b = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:2', batchVersion: 7, appointmentNo: 'AP-B' } c.agreements = [{ ...agreement('CANCELLED'), members: [a, b] }] vi.mocked(api.getReceivingContext).mockResolvedValue(c) vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: [a, b], excludedMembers: [], transport: c.transport, executionScope: { ...c.executionScope, version: 'whole-A-B' } }) vi.mocked(api.getReceivingExecutionScope).mockImplementation(async selected => ({ version: selected.length === 1 ? 'only-B-current' : 'whole-A-B-current', members: selected.map(member => ({ member, completed: false, canRebook: true, reason: null })) })) const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises() await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false); await flushPromises() expect((wrapper.get('[aria-label="确认当前范围"]').element as HTMLInputElement).checked).toBe(false) expect(api.getReceivingExecutionScope).toHaveBeenLastCalledWith([{ batchKey: b.batchKey, customerId: b.customerId }]) Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'B-ONLY', appointmentAt: '2026-10-24T10:00:00' }) await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅乙当前批确认'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true) await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.bookReceiving).toHaveBeenCalledWith(expect.objectContaining({ executionVersion: 'only-B-current', cancelledAgreementVersion: 4, bookings: [expect.objectContaining({ members: [{ batchKey: b.batchKey, batchVersion: 7, customerId: b.customerId }] })] })) wrapper.unmount() }) it('blocks submission after a selected execution-scope read failure and retries only that read', async () => { const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises() vi.mocked(api.getReceivingExecutionScope).mockRejectedValueOnce(new Error('执行资格暂不可用')) await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false); await flushPromises() expect(wrapper.get('[aria-label="确认当前范围"]').attributes('disabled')).toBeDefined(); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined() await button(wrapper, '重试读取').trigger('click'); await flushPromises() expect(api.getReceivingExecutionScope).toHaveBeenLastCalledWith([{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-b' }]) expect((wrapper.get('[aria-label="确认当前范围"]').element as HTMLInputElement).checked).toBe(false) expect(api.bookReceiving).not.toHaveBeenCalled(); wrapper.unmount() }) it('shows authoritative subset blockers and ignores a late response for a previous selection', async () => { const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises() let resolveOld!: (scope: api.ReceivingExecutionScope) => void vi.mocked(api.getReceivingExecutionScope).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false) vi.mocked(api.getReceivingExecutionScope).mockImplementationOnce(async selected => ({ version: 'new-blocked', members: selected.map(member => ({ member, completed: true, canRebook: false, reason: '本客户已执行,不能重约' })) })) await wrapper.get('[aria-label="选择预约客户 1"]').setValue(true); await flushPromises() resolveOld({ version: 'stale-allowed', members: [{ member: { batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-b' }, completed: false, canRebook: true, reason: null }] }); await flushPromises() expect(wrapper.text()).toContain('本客户已执行,不能重约') await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined(); wrapper.unmount() }) function verifiedCargo(): CargoVerificationContext { return { source: { sourceKey: 'BOOKING:2', customerId: 'customer-b', orderId: null, inboundCode: 'IN-B', bookingNo: null, containerNo: null, originWarehouseId: 'origin-1', originWarehouseName: '本仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 40, sourceVersion: 'source-latest', canFollowup: true, blockReasons: [] }, verification: { version: 2, sourceVersion: 'source-latest', destinationKey: 'FBA:ONT8', sourcePackages: 40, packageCount: 40, shipmentReferences: ['FBA-NEW-B'], remark: null, operatorName: '核实人员', verifiedAt: '2026-10-16T10:00:00Z' }, allocatedPackages: 30, unknownAllocationCount: 0, effectivePackages: 40, availablePackages: 10, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] } } async function enterRefresh(wrapper: ReturnType) { await flushPromises(); await wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')[1]!.trigger('click'); await flushPromises() } it('explicitly adopts only B latest source and verification snapshots beside independently confirmed A without changing quantities or saving verification', async () => { vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()); vi.mocked(getCargoVerification).mockResolvedValue(verifiedCargo()) const wrapper = render(); await enterRefresh(wrapper) expect(getCargoVerification).toHaveBeenCalledExactlyOnceWith({ sourceKey: 'BOOKING:2', customerId: 'customer-b', destinationKey: 'FBA:ONT8' }) expect(wrapper.text()).toContain('本批 30 箱'); expect(wrapper.text()).toContain('最新核实 40 箱'); expect(wrapper.text()).toContain('FBA-NEW-B') expect(api.refreshReceivingCargo).not.toHaveBeenCalled() await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.refreshReceivingCargo).toHaveBeenCalledWith(expect.objectContaining({ member: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-b' }, executionVersion: 'execution-v1', sources: [{ sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', expectedSourceVersion: 'source-latest', expectedVerificationVersion: 2 }] })) expect(saveCargoVerification).not.toHaveBeenCalled(); expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(api.bookReceiving).not.toHaveBeenCalled() await button(wrapper, '继续本客户目标 / 未约成').trigger('click'); await flushPromises() expect(wrapper.find('[aria-label="客户计划日期"]').exists()).toBe(true) wrapper.unmount() }) it('fails closed for unknown, changed, overallocated, mismatched or unavailable latest cargo', async () => { const cases: Array> = [ { sourceChanged: true }, { effectivePackages: null, availablePackages: null }, { unknownAllocationCount: 1 }, { allocatedPackages: 41 }, { source: { ...verifiedCargo().source, customerId: 'customer-other' } }, { source: { ...verifiedCargo().source, destinationKey: 'FBA:OTHER' } }, { source: { ...verifiedCargo().source, sourceVersion: null } }, { verification: { ...verifiedCargo().verification!, sourceVersion: 'stale' } }, { blockReasons: ['来源暂不可用'] }, ] for (const change of cases) { vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()); vi.mocked(getCargoVerification).mockResolvedValue({ ...verifiedCargo(), ...change }) const wrapper = render(); await enterRefresh(wrapper) expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined() await wrapper.get('form').trigger('submit'); await flushPromises(); expect(api.refreshReceivingCargo).not.toHaveBeenCalled(); wrapper.unmount() } vi.mocked(getCargoVerification).mockRejectedValueOnce(new Error('核实来源读取失败')) const wrapper = render(); await enterRefresh(wrapper) expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('最新货物核实资料读取失败'); wrapper.unmount() }) it('refreshes rejected adoption versions for explicit reconfirmation and retries unknown adoption with the identical key', async () => { const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c); vi.mocked(getCargoVerification).mockResolvedValue(verifiedCargo()) vi.mocked(api.refreshReceivingCargo).mockRejectedValueOnce(new ApiResponseError('资料版本变化', 'A0409', 409)).mockRejectedValueOnce(new Error('timeout')) const wrapper = render(); await enterRefresh(wrapper) const fresh = structuredClone(c); fresh.batch.version = 4; fresh.executionScope.version = 'exec-new' vi.mocked(api.getReceivingContext).mockResolvedValue(fresh); vi.mocked(getCargoVerification).mockResolvedValue({ ...verifiedCargo(), verification: { ...verifiedCargo().verification!, version: 3 } }) await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined() await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises() const second = vi.mocked(api.refreshReceivingCargo).mock.calls[1]![0] expect(second.member.batchVersion).toBe(4); expect(second.sources[0]!.expectedVerificationVersion).toBe(3); expect(second.executionVersion).toBe('exec-new'); expect(await guard()).toBe(false) await wrapper.get('form').trigger('submit'); await flushPromises() expect(api.refreshReceivingCargo).toHaveBeenNthCalledWith(3, second); expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount() }) it('requires original source read permissions and blocks adoption for confirmed or waiting-change customers', async () => { const c = mixedPlans(); c.pendingChanges = [{ ...pendingChange(), intent: { ...pendingChange().intent, customerId: 'customer-b' } }] vi.mocked(api.getReceivingContext).mockResolvedValue(c) const wrapper = render(); await flushPromises() const buttons = wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料') expect(buttons[0]!.attributes('disabled')).toBeDefined(); expect(buttons[1]!.attributes('disabled')).toBeDefined(); wrapper.unmount() access.denied = ['platform:overseas-warehouse:last-mile-order:list']; vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()) const denied = render(); await enterRefresh(denied) expect(getCargoVerification).not.toHaveBeenCalled(); expect(api.refreshReceivingCargo).not.toHaveBeenCalled(); denied.unmount() })