2026-10-20 23:34:11 +08:00
import { DatePickerStub , TimePickerStub } from './temporal-picker.test-support'
2026-10-16 15:41:56 +08:00
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 : {
2026-10-20 23:34:11 +08:00
ElDatePicker : DatePickerStub , ElTimePicker : TimePickerStub ,
2026-10-16 15:41:56 +08:00
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 })
2026-10-21 23:20:10 +08:00
c . plans = [{ batchKey : c.batch.batchKey ! , customerId : 'customer-b' , timezone : 'America/Los_Angeles' , details : appointmentPayload ({ ... createAppointmentForm (), appointmentStatus : 'PENDING' , plannedDate : '2026-10-24' , appointmentRemark : '等待仓库' }) }]
2026-10-16 15:41:56 +08:00
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 : '旧批原因' })
2026-10-21 23:20:10 +08:00
if ( ownPlan ) c . plans = [{ batchKey : c.batch.batchKey ! , customerId : 'customer-a' , timezone : c.batch.timezone || null , details : appointmentPayload ({ ... createAppointmentForm (), appointmentStatus : 'PENDING' }) }]
2026-10-16 15:41:56 +08: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 ( '' )
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 ()
})