Files
admin-web/src/modules/appointment-calendar/WorkReceivingPanel.test.ts
T

511 lines
54 KiB
TypeScript

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<typeof import('./receivingApi')>(), 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: {
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div data-booking-fields />' },
LoadErrorState: { props: ['message', 'showRetry'], template: '<section role="alert">{{ message }}<button v-if="showRetry !== false" @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
} } }) }
function button(wrapper: ReturnType<typeof render>, 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<typeof render>, 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<typeof render>) {
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<typeof render>) {
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<Partial<CargoVerificationContext>> = [
{ 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()
})