2026-10-11 13:07:52 +08:00
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
2026-10-07 16:46:57 +08:00
import { afterEach , beforeEach , expect , it , vi } from 'vitest'
import { shallowMount , flushPromises , type VueWrapper } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { ElMessageBox } from 'element-plus'
import CapacityOffersView from './CapacityOffersView.vue'
import CapacityOfferDetailView from './CapacityOfferDetailView.vue'
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
import CapacityOfferSettingsDialog from '../components/CapacityOfferSettingsDialog.vue'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import * as api from '../api/capacityOffers'
import type { CapacityOffer , CapacityInquiry } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
2026-10-11 15:33:09 +08:00
const state = vi . hoisted (() => ({ manage : false , write : true , quote : false , links : new Set < string >(), route : { query : {} as Record < string , string >, name : 'overseas-master-data-capacity-offer-detail' , params : { offerNo : 'CO-1' } }, push : vi.fn () }))
2026-10-07 16:46:57 +08:00
vi . mock ( 'vue-router' , () => ({ useRoute : () => state . route , useRouter : () => ({ push : state.push }) }))
2026-10-10 10:44:07 +08:00
vi . mock ( '@/core/access/accessPreview' , () => ({ useAccessPreviewStore : () => ({ can : ( p : string ) => state . links . has ( p ) || ( p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p . includes ( ':inquiry-' ) && state . write ) }) }))
2026-10-07 16:46:57 +08:00
vi . mock ( 'element-plus' , () => ({ ElMessage : { success : vi.fn (), warning : vi.fn () }, ElMessageBox : { confirm : vi.fn (). mockResolvedValue ( undefined ) } }))
2026-10-09 22:05:51 +08:00
vi . mock ( '../api/capacityOffers' , () => ({ listCapacityOffers : vi.fn (), listCapacityInquiries : vi.fn (), getCapacityOffer : vi.fn (), getCapacityInquiry : vi.fn (), saveCapacityInquiry : vi.fn (), saveAndConfirmInquiry : vi.fn (), commandCapacityInquiry : vi.fn (), listOfferCustomers : vi.fn (), listOfferQuotes : vi.fn (), saveCapacityOffer : vi.fn (), commandCapacityOffer : vi.fn () }))
2026-10-07 16:46:57 +08:00
export const offer : CapacityOffer = { offerNo : 'CO-1' , tripNo : 'TR-1' , status : 'OPEN' , recordVersion : 2 , tripRecordVersion : 5 , warehouseId : '92' , warehouseName : '测试起运仓' , plannedDepartureAt : '2026-10-10T12:00:00Z' , cutoffAt : '2026-10-10T10:00:00Z' , contactName : '调度' , contactPhone : '测试联系信息' , remark : null , capacityKnown : false , canCreateInquiry : true , canConfirm : false , blockReason : '待调度核对' , destinations : [{ destinationKey : 'FBA:ONT8' , name : 'ONT8' , deliveryIndex : 1 , remaining : null }], segments : [], actions : { canEdit : true , canPublish : false , canPause : true , canResume : false , canClose : true } }
2026-10-09 22:05:51 +08:00
export const inquiry : CapacityInquiry = { bookingNo : 'FB-1' , offerNo : 'CO-1' , tripNo : 'TR-1' , status : 'DRAFT' , recordVersion : 1 , offerRecordVersion : 2 , tripRecordVersion : 5 , salesOwnerUserId : '17' , customerId : '19' , customerCode : 'C-19' , customerName : '测试客户' , businessType : 'US_TRUCK' , deliveryMode : 'SELF_DELIVERY' , estimatedArrivalAt : null , expectedPalletCount : null , cargo : { destinationKey : 'FBA:ONT8' , cargoName : null , packageCount : null , palletSpaces : null , weightKg : null , volumeCbm : null , priceBasis : 'PALLET' , unitPrice : null , currency : 'USD' , contactName : null , contactPhone : null , remark : null }, destinationLabel : 'ONT8' , agreedAmount : null , quoteSource : null , businessOrderNo : null , incomingNo : null , dispatchNo : null , updatedAt : '2026-10-07T00:00:00Z' , actions : { canEdit : true , canConfirm : false , canCancel : true , blockReason : null }, appointmentCoverage : null , progress : { stage : 'NEEDS_DETAILS' , label : '待补资料' , reservation : '尚未保位' , owner : '销售' , primaryAction : 'EDIT' , message : '确认前还需补充资料' , missingFields : [ '预计重量' ] } }
2026-10-07 16:46:57 +08:00
const pageOf = < T ,>( records : T [], total = records . length ) => ({ records , total , page : 1 , size : 20 , pages : Math.ceil ( total / 20 ) })
const wrappers : VueWrapper [] = []
const layout = { template : '<div><slot name="header"/><slot/></div>' }
const field = { props : [ 'modelValue' ], emits : [ 'update:modelValue' , 'focus' ], template : '<input />' }
2026-10-10 15:33:10 +08:00
function mount ( component : object , props : Record < string , unknown > = {}) : { wrapper : VueWrapper ; pinia : ReturnType < typeof createPinia > } {
2026-10-07 16:46:57 +08:00
const pinia = createPinia ()
const wrapper = shallowMount ( component , { props , global : { plugins : [ pinia ], directives : { loading : () => undefined }, stubs : {
AdminPage : layout , ListPageLayout : layout , DetailPageLayout : layout ,
2026-10-07 18:21:03 +08:00
DataTable : { name : 'DataTable' , props : [ 'data' , 'page' , 'pageSize' , 'total' ], emits : [ 'update:page' ], template : '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="loads" :row="row"/></div><slot/></div>' },
2026-10-07 16:46:57 +08:00
QueryToolbar : { name : 'QueryToolbar' , props : [ 'modelValue' , 'fields' ], emits : [ 'update:modelValue' , 'search' ], template : '<div/>' },
CapacityInquiryTable : { name : 'CapacityInquiryTable' , props : [ 'rows' , 'total' , 'failed' ], emits : [ 'action' , 'update:page' ], template : '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' },
CapacityInquiryDialog : component === CapacityInquiryDialog ? false : true ,
2026-10-07 18:21:03 +08:00
AppDialog : { name : 'AppDialog' , props : [ 'modelValue' , 'showConfirm' , 'confirmText' , 'beforeClose' ], emits : [ 'update:modelValue' , 'confirm' ], template : '<div v-if="modelValue"><slot/></div>' },
2026-10-07 16:46:57 +08:00
ElButton : { props : [ 'disabled' , 'loading' ], template : '<button :disabled="disabled || loading"><slot/></button>' }, ElForm : layout , ElFormItem : layout ,
ElInput : { ... field , name : 'ElInput' }, ElSelect : { ... field , name : 'ElSelect' }, ElInputNumber : { ... field , name : 'ElInputNumber' }, ElDatePicker : { ... field , name : 'ElDatePicker' }, ElOption : true , ApiErrorMeta : true , LoadErrorState : true ,
} } }); wrappers . push ( wrapper ); return { wrapper , pinia }
}
const button = ( w : VueWrapper , label : string ) => w . findAll ( 'button' ). find ( b => b . text () === label ) !
2026-10-11 15:33:09 +08:00
beforeEach (() => { state . route . query = {}; vi . clearAllMocks (); state . manage = false ; state . write = true ; state . quote = false ; state . links . clear (); vi . mocked ( ElMessageBox . confirm ). mockImplementation ( vi . fn (). mockResolvedValue ( undefined )); vi . mocked ( api . listCapacityOffers ). mockResolvedValue ( pageOf ([ structuredClone ( offer )], 41 )); vi . mocked ( api . listCapacityInquiries ). mockResolvedValue ( pageOf ([ structuredClone ( inquiry )])); vi . mocked ( api . getCapacityOffer ). mockResolvedValue ({ offer : structuredClone ( offer ), inquiries : pageOf ([ structuredClone ( inquiry )]) }) })
2026-10-07 16:46:57 +08:00
afterEach (() => { wrappers . splice ( 0 ). forEach ( w => w . unmount ()); vi . useRealTimers () })
2026-10-11 14:14:49 +08:00
it . each ([ 'DRAFT' , 'CONFIRMED' , 'RECEIVED' , 'BOUND' , 'CANCELLED' ] as const )( 'labels %s cargo consistently in the list and detail without treating estimated pallets as actual' , async status => {
const row : CapacityInquiry = { ... inquiry , status , expectedPalletCount : 7 , cargo : { ... inquiry . cargo , packageCount : 12 , palletSpaces : 3 , weightKg : 80 , volumeCbm : 2 } }
const label = status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
const list = shallowMount ( CapacityInquiryTable , { props : { rows : [ row ], total : 1 , loading : false }, global : { stubs : {
DataTable : { props : [ 'data' , 'columns' ], template : '<div><slot name="cargo" :row="data[0]"/></div>' },
} } })
wrappers . push ( list )
expect ( list . text ()). toContain ( ` ${ label } : 12 箱 · 3 托位 · 80 kg · 2 m³`)
expect ( list . text ()). toContain ( '预计实托数:7' )
expect ( list . text ()). not . toContain ( '7 实托' )
await list . setProps ({ rows : [{ ... row , expectedPalletCount : null , cargo : { ... row . cargo , packageCount : null } }] })
expect ( list . text ()). toContain ( ` ${ label } :— 箱` )
expect ( list . text ()). toContain ( '预计实托数:—' )
const { wrapper : detail } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : row , readonly : true })
await flushPromises ()
for ( const suffix of [ '箱数' , '标准托位' , '重量(kg) ' , '体积(m³)' ]) expect ( detail . find ( `[label=" ${ label }${ suffix } "]` ). exists ()). toBe ( true )
expect ( detail . findAll ( 'summary' ). map ( item => item . text ())). toContain ( label === '已核实' ? '已核实货量与预计实托数' : ` ${ label } 货量` )
expect ( detail . find ( '[label="预计实托数"]' ). exists ()). toBe ( true )
expect ( detail . find ( '[label="已核实实托数"]' ). exists ()). toBe ( false )
expect ( api . saveCapacityInquiry ). not . toHaveBeenCalled ()
})
2026-10-07 16:46:57 +08:00
it ( 'loads only the active tab and sends exactly one query after resetting from a later page' , async () => {
const { wrapper } = mount ( CapacityOffersView ); await flushPromises ()
expect ( api . listCapacityOffers ). toHaveBeenCalledOnce (); expect ( api . listCapacityInquiries ). not . toHaveBeenCalled ()
2026-10-07 18:21:03 +08:00
expect ( wrapper . text ()). toContain ( '待调度核对' )
2026-10-07 16:46:57 +08:00
wrapper . findComponent ({ name : 'DataTable' }). vm . $emit ( 'update:page' , 2 ); await flushPromises ()
wrapper . findComponent ({ name : 'QueryToolbar' }). vm . $emit ( 'search' ); await flushPromises ()
expect ( api . listCapacityOffers ). toHaveBeenCalledTimes ( 3 ); expect ( api . listCapacityOffers ). toHaveBeenLastCalledWith ( expect . objectContaining ({ page : 1 }))
2026-10-09 22:05:51 +08:00
await button ( wrapper , '我的登记' ). trigger ( 'click' ); await flushPromises ()
2026-10-07 16:46:57 +08:00
expect ( api . listCapacityInquiries ). toHaveBeenCalledExactlyOnceWith ( expect . objectContaining ({ scope : 'MINE' })); expect ( api . getCapacityOffer ). not . toHaveBeenCalled ()
const query = wrapper . findComponent ({ name : 'QueryToolbar' }); query . vm . $emit ( 'update:modelValue' , { scope : 'ALL' }); await flushPromises (); query . vm . $emit ( 'search' ); await flushPromises ()
expect ( api . listCapacityInquiries ). toHaveBeenLastCalledWith ( expect . objectContaining ({ scope : 'MINE' }))
})
it ( 'ignores an older page response after changing tabs and retains an explicit error state' , async () => {
let resolve !: ( value : ReturnType < typeof pageOf < CapacityOffer >>) => void
vi . mocked ( api . listCapacityOffers ). mockReturnValue ( new Promise ( done => { resolve = done }))
2026-10-09 22:05:51 +08:00
const { wrapper } = mount ( CapacityOffersView ); await button ( wrapper , '我的登记' ). trigger ( 'click' ); await flushPromises ()
2026-10-07 16:46:57 +08:00
resolve ( pageOf ([ offer ], 900 )); await flushPromises ()
expect ( wrapper . findComponent ({ name : 'CapacityInquiryTable' }). props ( 'total' )). toBe ( 1 )
vi . mocked ( api . listCapacityInquiries ). mockRejectedValue ( new Error ( '读取失败' )); wrapper . findComponent ({ name : 'QueryToolbar' }). vm . $emit ( 'search' ); await flushPromises ()
expect ( wrapper . findComponent ({ name : 'LoadErrorState' }). exists ()). toBe ( true )
expect ( wrapper . findComponent ({ name : 'CapacityInquiryTable' }). props ( 'failed' )). toBe ( true )
})
it ( 'reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes' , async () => {
const { wrapper } = mount ( CapacityOfferDetailView ); await flushPromises ()
expect ( api . getCapacityOffer ). toHaveBeenCalledOnce (); expect ( api . listCapacityInquiries ). not . toHaveBeenCalled ()
2026-10-09 22:05:51 +08:00
await button ( wrapper , '登记客户货物' ). trigger ( 'click' ); await flushPromises ()
2026-10-07 16:46:57 +08:00
expect ( wrapper . findComponent ({ name : 'CapacityInquiryDialog' }). props ( 'modelValue' )). toBe ( true )
expect ( api . listOfferCustomers ). not . toHaveBeenCalled (); expect ( api . listOfferQuotes ). not . toHaveBeenCalled ()
})
it ( 'preserves nullable estimates and retries the original immutable create request after an uncertain result' , async () => {
vi . mocked ( api . saveCapacityInquiry ). mockRejectedValueOnce ( new Error ( 'network' )). mockResolvedValueOnce ({ offer , inquiry , replayed : true })
const { wrapper , pinia } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : null , readonly : false }); await flushPromises ()
2026-10-10 15:33:10 +08:00
const selects = wrapper . findAllComponents ({ name : 'ElSelect' }); selects [ 0 ] ! . vm . $emit ( 'update:modelValue' , inquiry . customerId ); selects [ 1 ] ! . vm . $emit ( 'update:modelValue' , inquiry . cargo . destinationKey ); await flushPromises ()
2026-10-07 16:46:57 +08:00
const dialog = wrapper . findComponent ({ name : 'AppDialog' }); dialog . vm . $emit ( 'confirm' ); await flushPromises ()
const first = vi . mocked ( api . saveCapacityInquiry ). mock . calls [ 0 ] ! [ 2 ]
expect ( first ). toMatchObject ({ expectedPalletCount : null , cargo : { packageCount : null , palletSpaces : null }, idempotencyKey : expect.any ( String ) })
expect ( useUnsavedWorkStore ( pinia ). findBlocking ( new Set (), true )). toHaveLength ( 1 )
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . saveCapacityInquiry ). toHaveBeenNthCalledWith ( 2 , offer . offerNo , null , first )
expect ( wrapper . emitted ( 'saved' )). toHaveLength ( 1 ); expect ( api . listOfferCustomers ). not . toHaveBeenCalled ()
})
it ( 'protects X/Esc/cancel model-close requests and does not emit close when discard is declined' , async () => {
vi . mocked ( ElMessageBox . confirm ). mockRejectedValueOnce ( 'cancel' )
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry , readonly : false }); await flushPromises ()
wrapper . findAllComponents ({ name : 'ElInput' })[ 0 ] ! . vm . $emit ( 'update:modelValue' , '改名' ); await flushPromises ()
2026-10-07 18:21:03 +08:00
const done = vi . fn ()
await wrapper . findComponent ({ name : 'AppDialog' }). props ( 'beforeClose' )( done ); await flushPromises ()
expect ( ElMessageBox . confirm ). toHaveBeenCalledOnce (); expect ( done ). not . toHaveBeenCalled (); expect ( wrapper . emitted ( 'update:modelValue' )). toBeUndefined ()
2026-10-07 16:46:57 +08:00
})
it ( 'retains an uncertain request and route protection after closing and reopening its form' , async () => {
vi . mocked ( api . saveCapacityInquiry ). mockRejectedValue ( new Error ( 'network' ))
const { wrapper , pinia } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry , readonly : false }); await flushPromises ()
wrapper . findComponent ({ name : 'AppDialog' }). vm . $emit ( 'confirm' ); await flushPromises ()
const first = vi . mocked ( api . saveCapacityInquiry ). mock . calls [ 0 ] ! [ 2 ]
await ( wrapper as VueWrapper < Record < string , unknown >>). setProps ({ modelValue : false }); await flushPromises ()
expect ( useUnsavedWorkStore ( pinia ). findBlocking ( new Set (), true )). toHaveLength ( 1 )
await ( wrapper as VueWrapper < Record < string , unknown >>). setProps ({ modelValue : true }); await flushPromises ()
expect ( useUnsavedWorkStore ( pinia ). findBlocking ( new Set (), true )). toHaveLength ( 1 )
wrapper . findComponent ({ name : 'AppDialog' }). vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . saveCapacityInquiry ). toHaveBeenNthCalledWith ( 2 , offer . offerNo , inquiry . bookingNo , first )
})
it ( 'keeps sales from confirming even if a stale row advertises the action' , async () => {
const row = { ... inquiry , actions : { ... inquiry . actions , canConfirm : true } }
2026-10-09 22:05:51 +08:00
const { wrapper } = mount ( CapacityOffersView ); await flushPromises (); await button ( wrapper , '我的登记' ). trigger ( 'click' ); await flushPromises ()
2026-10-07 16:46:57 +08:00
wrapper . findComponent ({ name : 'CapacityInquiryTable' }). vm . $emit ( 'action' , 'confirm' , row ); await flushPromises ()
expect ( api . commandCapacityInquiry ). not . toHaveBeenCalled (); expect ( ElMessageBox . confirm ). not . toHaveBeenCalled ()
})
it ( 'loads publishing settings only when explicitly opened, not with the trip page' , async () => {
state . manage = true
const { wrapper } = mount ( CapacityOfferSettingsDialog , { modelValue : false , trip : { tripNo : 'TR-1' , recordVersion : 5 } as FleetTrip }); await flushPromises ()
expect ( api . listCapacityOffers ). not . toHaveBeenCalled (); await ( wrapper as VueWrapper < Record < string , unknown >>). setProps ({ modelValue : true }); await flushPromises ()
2026-10-08 14:11:54 +08:00
expect ( api . listCapacityOffers ). toHaveBeenCalledExactlyOnceWith ({ scope : 'ALL' , tripNo : 'TR-1' , page : 1 , size : 1 })
2026-10-07 16:46:57 +08:00
})
2026-10-09 22:05:51 +08:00
it ( 'retries an uncertain combined save and confirmation with the original payload' , async () => {
2026-10-07 16:46:57 +08:00
state . manage = true
const row = { ... inquiry , actions : { ... inquiry . actions , canConfirm : true } }
2026-10-09 22:05:51 +08:00
vi . mocked ( api . saveAndConfirmInquiry ). mockRejectedValueOnce ( new Error ( 'network' )). mockResolvedValueOnce ({ offer , inquiry : { ... row , status : 'CONFIRMED' }, replayed : true })
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : row , readonly : false , confirmMode : true }); await flushPromises ()
const dialog = wrapper . findComponent ({ name : 'AppDialog' })
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
const first = vi . mocked ( api . saveAndConfirmInquiry ). mock . calls [ 0 ] !
expect ( first [ 0 ]). toBe ( row . bookingNo )
expect ( wrapper . emitted ( 'saved' )). toBeUndefined ()
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . saveAndConfirmInquiry ). toHaveBeenNthCalledWith ( 2 , ... first )
expect ( api . saveCapacityInquiry ). not . toHaveBeenCalled ()
expect ( wrapper . emitted ( 'saved' )). toHaveLength ( 1 )
2026-10-07 16:46:57 +08:00
})
2026-10-08 14:11:54 +08:00
it ( 'keeps the create button disabled with a reason after cutoff, while hiding it without write permission' , async () => {
vi . mocked ( api . getCapacityOffer ). mockResolvedValue ({ offer : { ... offer , canCreateInquiry : false , blockReason : '交货截止已过或不符合当前发车时间' }, inquiries : pageOf ([]) })
const { wrapper } = mount ( CapacityOfferDetailView ); await flushPromises ()
2026-10-09 22:05:51 +08:00
expect ( button ( wrapper , '登记客户货物' ). attributes ( 'disabled' )). toBeDefined ()
expect ( wrapper . text ()). toContain ( '无法登记客户货物:交货截止已过' )
2026-10-08 14:11:54 +08:00
expect ( wrapper . findComponent ({ name : 'CapacityInquiryDialog' }). props ( 'modelValue' )). toBe ( false )
state . write = false
const readonly = mount ( CapacityOfferDetailView ); await flushPromises ()
2026-10-09 22:05:51 +08:00
expect ( readonly . wrapper . findAll ( 'button' ). some ( node => node . text () === '登记客户货物' )). toBe ( false )
2026-10-08 14:11:54 +08:00
})
2026-10-10 15:33:10 +08:00
it ( 'defaults minimal managers to their TODO inquiries without loading offers, and explicitly switches to available offers' , async () => {
2026-10-08 14:11:54 +08:00
state . manage = true
const { wrapper } = mount ( CapacityOffersView ); await flushPromises ()
2026-10-10 15:33:10 +08:00
expect ( api . listCapacityInquiries ). toHaveBeenCalledExactlyOnceWith ( expect . objectContaining ({ scope : 'TODO' }))
expect ( api . listCapacityOffers ). not . toHaveBeenCalled ()
expect ( state . links . size ). toBe ( 0 )
await button ( wrapper , '可拼车次' ). trigger ( 'click' ); await flushPromises ()
expect ( api . listCapacityOffers ). toHaveBeenCalledExactlyOnceWith ( expect . objectContaining ({ scope : 'AVAILABLE' }))
2026-10-08 14:11:54 +08:00
const query = wrapper . findComponent ({ name : 'QueryToolbar' })
query . vm . $emit ( 'update:modelValue' , { scope : 'ALL' }); await flushPromises (); query . vm . $emit ( 'search' ); await flushPromises ()
expect ( api . listCapacityOffers ). toHaveBeenLastCalledWith ( expect . objectContaining ({ scope : 'ALL' }))
query . vm . $emit ( 'update:modelValue' , {}); await flushPromises (); query . vm . $emit ( 'search' ); await flushPromises ()
expect ( api . listCapacityOffers ). toHaveBeenLastCalledWith ( expect . objectContaining ({ scope : 'AVAILABLE' }))
})
2026-10-09 22:05:51 +08:00
it ( 'automatically selects a single destination and preserves unknown quantities when only the customer is known' , async () => {
vi . mocked ( api . saveCapacityInquiry ). mockResolvedValue ({ offer , inquiry , replayed : false })
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : null , readonly : false }); await flushPromises ()
wrapper . findAllComponents ({ name : 'ElSelect' })[ 0 ] ! . vm . $emit ( 'update:modelValue' , '19' ); await flushPromises ()
wrapper . findComponent ({ name : 'AppDialog' }). vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . saveCapacityInquiry ). toHaveBeenCalledWith ( offer . offerNo , null , expect . objectContaining ({ customerId : '19' , cargo : expect.objectContaining ({ destinationKey : 'FBA:ONT8' , packageCount : null , palletSpaces : null , weightKg : null , volumeCbm : null }) }))
})
it ( 'requires an explicit choice for multiple destinations' , async () => {
const many = { ... offer , destinations : [... offer . destinations , { ... offer . destinations [ 0 ] ! , destinationKey : 'FBA:ACY2' , name : 'ACY2' , deliveryIndex : 2 }] }
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer : many , inquiry : null , readonly : false }); await flushPromises ()
wrapper . findAllComponents ({ name : 'ElSelect' })[ 0 ] ! . vm . $emit ( 'update:modelValue' , '19' ); await flushPromises ()
wrapper . findComponent ({ name : 'AppDialog' }). vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . saveCapacityInquiry ). not . toHaveBeenCalled ()
})
2026-10-10 10:44:07 +08:00
// Navigation must retain the exact bound business references and independent read permissions.
it . each ([ CapacityOffersView , CapacityOfferDetailView ])( 'opens the bound trip separately from read-only dispatch detail' , async component => {
const row = { ... inquiry , dispatchNo : 'DS-BOUND' , status : 'BOUND' as const }
state . links = new Set ([ 'platform:fleet:trip:list' , 'platform:overseas-warehouse:dispatch:list' ])
const { wrapper } = mount ( component ); await flushPromises ()
const dialog = wrapper . findComponent ( CapacityInquiryDialog )
dialog . vm . $emit ( 'action' , 'dispatch' , row ); await flushPromises ()
2026-10-11 15:33:09 +08:00
expect ( state . push ). toHaveBeenLastCalledWith ({ name : 'overseas-dispatch-detail' , params : { dispatchNo : 'DS-BOUND' }, query : { fleetSource : 'inquiry' , fleetOfferNo : row.offerNo , fleetBookingNo : row.bookingNo } })
2026-10-10 10:44:07 +08:00
dialog . vm . $emit ( 'action' , 'trip' , row ); await flushPromises ()
2026-10-11 15:33:09 +08:00
expect ( state . push ). toHaveBeenLastCalledWith ({ name : 'overseas-master-data-trip-detail' , params : { tripNo : row.tripNo }, query : { fleetSource : 'inquiry' , fleetOfferNo : row.offerNo , fleetBookingNo : row.bookingNo } })
2026-10-10 10:44:07 +08:00
expect ( api . commandCapacityInquiry ). not . toHaveBeenCalled ()
state . links . clear (); state . push . mockClear ()
dialog . vm . $emit ( 'action' , 'trip' , row ); dialog . vm . $emit ( 'action' , 'dispatch' , row ); await flushPromises ()
expect ( state . push ). not . toHaveBeenCalled ()
})
it ( 'shows follow-up destinations only with the matching read permission' , async () => {
const row = { ... inquiry , dispatchNo : 'DS-BOUND' , status : 'BOUND' as const }
state . links . add ( 'platform:overseas-warehouse:dispatch:list' )
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : row , readonly : true }); await flushPromises ()
expect ( button ( wrapper , '查看排车详情' )). toBeDefined ()
expect ( button ( wrapper , '前往车次审核' )). toBeUndefined ()
wrapper . unmount ()
state . links = new Set ([ 'platform:fleet:trip:list' ])
const second = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : row , readonly : true }); await flushPromises ()
expect ( button ( second . wrapper , '查看排车详情' )). toBeUndefined ()
await button ( second . wrapper , '前往车次审核' ). trigger ( 'click' )
expect ( second . wrapper . emitted ( 'action' )). toEqual ([[ 'trip' , row ]])
expect ( api . commandCapacityInquiry ). not . toHaveBeenCalled ()
})
2026-10-10 15:33:10 +08:00
it ( 'keeps initial inputs compact, reveals confirmation fields, and keeps unknown amounts null' , async () => {
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : null , readonly : false })
await flushPromises ()
const visibleInputs = wrapper . findAll ( 'input' ). filter ( input => ! input . element . closest ( 'details:not([open])' ))
expect ( visibleInputs ). toHaveLength ( 2 )
expect ( wrapper . text ()). toContain ( '业务类型:本土散板' )
const cargo = wrapper . findAll ( 'details' ). find ( section => section . find ( 'summary' ). text (). startsWith ( '已知货量' )) !
expect ( cargo . attributes ( 'open' )). toBeUndefined ()
await wrapper . setProps ({ confirmMode : true }); await flushPromises ()
expect ( cargo . attributes ( 'open' )). toBeDefined ()
expect ( wrapper . findAll ( 'details' ). find ( section => section . find ( 'summary' ). text (). startsWith ( '确认承运前待补资料' )) ! . attributes ( 'open' )). toBeDefined ()
})
it ( 'highlights only the allowed current step and keeps other actions secondary' , async () => {
state . manage = true
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry , readonly : true })
await flushPromises ()
const section = wrapper . find ( '[aria-label="继续处理本票货物"]' )
expect ( section . findAll ( 'button' ). filter ( item => ! item . element . closest ( 'details' )). map ( item => item . text ())). toEqual ([ '补充资料' ])
expect ( section . find ( 'details' ). text ()). toContain ( '核对并确认' )
await button ( wrapper , '补充资料' ). trigger ( 'click' )
expect ( wrapper . emitted ( 'action' ) ? .[ 0 ]). toEqual ([ 'edit' , inquiry ])
await wrapper . setProps ({ inquiry : { ... inquiry , progress : { ... inquiry . progress , primaryAction : 'INCOMING' }, actions : { ... inquiry . actions , canEdit : false , canConfirm : false } } })
expect ( section . findAll ( 'button' ). filter ( item => ! item . element . closest ( 'details' ))). toHaveLength ( 0 )
state . links . add ( 'platform:fleet:trip:booking-edit' )
await wrapper . setProps ({ inquiry : { ... inquiry , progress : { ... inquiry . progress , primaryAction : 'INCOMING' } } })
expect ( section . findAll ( 'button' ). filter ( item => ! item . element . closest ( 'details' )). map ( item => item . text ())). toEqual ([ '安排来货' ])
})
2026-10-11 13:07:52 +08:00
2026-10-19 17:56:31 +08:00
it . each ([
[ 'all current permissions' , [ 'platform:fleet:capacity-offer:manage' , 'platform:fleet:trip:booking-edit' , 'platform:fleet:trip:edit' , 'platform:overseas-warehouse:dispatch:create' ], true ],
[ 'legacy smart permission' , [ 'platform:fleet:capacity-offer:manage' , 'platform:fleet:trip:booking-edit' , 'platform:fleet:trip:edit' , 'platform:overseas-warehouse:inbound-order:smart-dispatch' ], false ],
[ 'missing offer manage' , [ 'platform:fleet:trip:booking-edit' , 'platform:fleet:trip:edit' , 'platform:overseas-warehouse:dispatch:create' ], false ],
[ 'missing booking edit' , [ 'platform:fleet:capacity-offer:manage' , 'platform:fleet:trip:edit' , 'platform:overseas-warehouse:dispatch:create' ], false ],
[ 'missing trip edit' , [ 'platform:fleet:capacity-offer:manage' , 'platform:fleet:trip:booking-edit' , 'platform:overseas-warehouse:dispatch:create' ], false ],
[ 'missing dispatch create' , [ 'platform:fleet:capacity-offer:manage' , 'platform:fleet:trip:booking-edit' , 'platform:fleet:trip:edit' ], false ],
])( 'keeps table and dialog arrange-dispatch authority aligned: %s' , async ( _ , codes , allowed ) => {
state . links = new Set ( codes )
state . manage = codes . includes ( 'platform:fleet:capacity-offer:manage' )
const row : CapacityInquiry = { ... inquiry , progress : { ... inquiry . progress , primaryAction : 'BIND' } }
const table = shallowMount ( CapacityInquiryTable , { props : { rows : [ row ], total : 1 , loading : false }, global : { stubs : {
DataTable : { props : [ 'data' ], template : '<div><slot name="actions" :row="data[0]" /></div>' },
RowActions : { name : 'RowActions' , props : [ 'actions' ], template : '<div />' },
} } })
wrappers . push ( table )
const actions = table . getComponent ({ name : 'RowActions' }). props ( 'actions' ) as { key : string ; visible? : boolean }[]
expect ( actions . find ( action => action . key === 'bind' ) ? . visible ). toBe ( allowed )
const { wrapper : dialog } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : row , readonly : true })
await flushPromises ()
const bind = dialog . findAll ( 'button' ). find ( button => button . text () === '确认排入本车' )
expect ( Boolean ( bind )). toBe ( allowed )
if ( bind ) { await bind . trigger ( 'click' ); expect ( dialog . emitted ( 'action' ) ? . at ( - 1 )). toEqual ([ 'bind' , row ]) }
})
2026-10-11 13:07:52 +08:00
it ( 'shows the selected destination capacity when confirming and never treats unknown capacity as zero' , async () => {
state . manage = true
const knownOffer = { ... offer , capacityKnown : true , destinations : [
{ ... offer . destinations [ 0 ] ! , remaining : { palletSpaces : 0 , weightKg : 12 , volumeCbm : 3 } },
{ destinationKey : 'FBA:ACY2' , name : 'ACY2' , deliveryIndex : 2 , remaining : { palletSpaces : 7 , weightKg : 800 , volumeCbm : 20 } },
] }
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer : knownOffer , inquiry , readonly : false , confirmMode : true })
await flushPromises ()
expect ( wrapper . get ( '[aria-label="本目的站可接余位"]' ). text ()). toContain ( '0 托位 · 12 kg · 3 m³' )
wrapper . findAllComponents ({ name : 'ElSelect' })[ 1 ] ! . vm . $emit ( 'update:modelValue' , 'FBA:ACY2' ); await flushPromises ()
expect ( wrapper . get ( '[aria-label="本目的站可接余位"]' ). text ()). toContain ( '7 托位 · 800 kg · 20 m³' )
await wrapper . setProps ({ offer : { ... knownOffer , capacityKnown : false } })
expect ( wrapper . get ( '[aria-label="本目的站可接余位"]' ). text ()). toContain ( '待调度核对' )
expect ( wrapper . get ( '[aria-label="本目的站可接余位"]' ). text ()). not . toContain ( '800' )
expect ( api . saveAndConfirmInquiry ). not . toHaveBeenCalled ()
})
it ( 'prioritizes the allowed current list action and keeps other actions in the overflow menu' , async () => {
state . manage = true
const wrapper = shallowMount ( CapacityInquiryTable , { props : { rows : [ inquiry ], total : 1 , loading : false }, global : { directives : { loading : () => undefined }, stubs : {
DataTable : { props : [ 'data' ], template : '<div><slot name="actions" :row="data[0]"/></div>' },
RowActions : { name : 'RowActions' , props : [ 'actions' , 'maxVisible' ], template : '<div/>' },
} } })
wrappers . push ( wrapper )
const actions = () => wrapper . findComponent ({ name : 'RowActions' }). props ( 'actions' ) as { key : string ; visible? : boolean ; priority? : number }[]
const primary = () => actions (). filter ( item => item . visible !== false ). sort (( a , b ) => ( b . priority || 0 ) - ( a . priority || 0 ))[ 0 ] ? . key
expect ( wrapper . findComponent ({ name : 'RowActions' }). props ( 'maxVisible' )). toBe ( 1 )
expect ( primary ()). toBe ( 'edit' )
await wrapper . setProps ({ rows : [{ ... inquiry , progress : { ... inquiry . progress , primaryAction : 'INCOMING' }, actions : { ... inquiry . actions , canEdit : false , canConfirm : false } }] })
expect ( primary ()). toBe ( 'view' )
state . links . add ( 'platform:fleet:trip:booking-edit' )
await wrapper . setProps ({ rows : [{ ... inquiry , progress : { ... inquiry . progress , primaryAction : 'INCOMING' } }] })
expect ( primary ()). toBe ( 'incoming' )
})
2026-10-11 15:33:09 +08:00
it ( 'shows the business identity and exposes the order link only with its list permission' , async () => {
const row = { ... inquiry , businessOrderNo : 'ORDER-1' }
const { wrapper } = mount ( CapacityInquiryDialog , { modelValue : true , offer , inquiry : row , readonly : true })
await flushPromises ()
const summary = wrapper . get ( '[aria-label="本票处理进度"]' ). text ()
expect ( summary ). toContain ( row . tripNo ); expect ( summary ). toContain ( row . bookingNo )
expect ( summary ). toContain ( row . customerName ); expect ( summary ). toContain ( row . destinationLabel )
expect ( button ( wrapper , 'ORDER-1' )). toBeUndefined ()
state . links . add ( 'platform:overseas-business:truck:list' )
await wrapper . setProps ({ inquiry : { ... row } }); await flushPromises ()
await button ( wrapper , 'ORDER-1' ). trigger ( 'click' )
expect ( wrapper . emitted ( 'action' ) ? .[ 0 ]). toEqual ([ 'business-order' , row ])
})
it ( 'reopens a returned inquiry through an authorized fetch and refuses a different offer' , async () => {
state . route . query = { inquiry : inquiry.bookingNo }
vi . mocked ( api . getCapacityInquiry ). mockResolvedValue ( inquiry )
const { wrapper } = mount ( CapacityOfferDetailView ); await flushPromises ()
const editor = wrapper . findComponent ({ name : 'CapacityInquiryDialog' })
expect ( editor . props ( 'modelValue' )). toBe ( true ); expect ( editor . props ( 'readonly' )). toBe ( true )
expect ( editor . props ( 'inquiry' ). bookingNo ). toBe ( inquiry . bookingNo )
wrapper . unmount ()
vi . mocked ( api . getCapacityInquiry ). mockResolvedValue ({ ... inquiry , offerNo : 'FO-OTHER' })
const rejected = mount ( CapacityOfferDetailView ). wrapper ; await flushPromises ()
expect ( rejected . findComponent ({ name : 'CapacityInquiryDialog' }). props ( 'modelValue' )). toBe ( false )
expect ( rejected . findComponent ({ name : 'LoadErrorState' }). exists ()). toBe ( true )
})
it ( 'opens refreshed server guidance after fulfillment succeeds without replaying a write' , async () => {
state . manage = true
const { wrapper } = mount ( CapacityOffersView ); await flushPromises ()
const updated = { ... inquiry , status : 'BOUND' as const , progress : { ... inquiry . progress , primaryAction : 'VIEW' , message : '服务端下一待办' , owner : '审核 / 仓库' } }
vi . mocked ( api . getCapacityInquiry ). mockResolvedValue ( updated )
wrapper . findComponent ({ name : 'CapacityInquiryTable' }). vm . $emit ( 'action' , 'bind' , inquiry ); await flushPromises ()
wrapper . findComponent ({ name : 'CapacityInquiryFulfillment' }). vm . $emit ( 'saved' ); await flushPromises ()
const editor = wrapper . findComponent ({ name : 'CapacityInquiryDialog' })
expect ( editor . props ( 'modelValue' )). toBe ( true ); expect ( editor . props ( 'readonly' )). toBe ( true )
expect ( editor . props ( 'inquiry' ). progress . message ). toBe ( '服务端下一待办' )
expect ( api . commandCapacityInquiry ). not . toHaveBeenCalled ()
})
2026-10-11 19:33:25 +08:00
it ( 'fetches an off-page returned inquiry once per refresh and retries without falling back to stale data' , async () => {
state . route . query = { inquiry : inquiry.bookingNo }
vi . mocked ( api . getCapacityOffer ). mockResolvedValue ({ offer , inquiries : pageOf < CapacityInquiry >([], 30 ) })
vi . mocked ( api . getCapacityInquiry ). mockResolvedValue ( inquiry )
const { wrapper } = mount ( CapacityOfferDetailView ); await flushPromises ()
const editor = wrapper . findComponent ({ name : 'CapacityInquiryDialog' })
expect ( editor . props ( 'modelValue' )). toBe ( true )
vi . mocked ( api . getCapacityOffer ). mockClear (); vi . mocked ( api . getCapacityInquiry ). mockClear ()
vi . mocked ( api . getCapacityInquiry ). mockResolvedValue ({ ... inquiry , recordVersion : 7 })
await button ( wrapper , '重新核对' ). trigger ( 'click' ); await flushPromises ()
expect ( api . getCapacityOffer ). toHaveBeenCalledOnce ()
expect ( api . getCapacityInquiry ). toHaveBeenCalledExactlyOnceWith ( inquiry . bookingNo )
expect ( editor . props ( 'inquiry' ). recordVersion ). toBe ( 7 )
vi . mocked ( api . getCapacityInquiry ). mockRejectedValueOnce ( new Error ( '读取失败' ))
await button ( wrapper , '重新核对' ). trigger ( 'click' ); await flushPromises ()
const error = wrapper . findComponent ({ name : 'LoadErrorState' })
expect ( error . exists ()). toBe ( true )
vi . mocked ( api . getCapacityOffer ). mockClear (); vi . mocked ( api . getCapacityInquiry ). mockClear ()
vi . mocked ( api . getCapacityInquiry ). mockResolvedValue ({ ... inquiry , recordVersion : 8 })
error . vm . $emit ( 'retry' ); await flushPromises ()
expect ( api . getCapacityOffer ). toHaveBeenCalledOnce ()
expect ( api . getCapacityInquiry ). toHaveBeenCalledExactlyOnceWith ( inquiry . bookingNo )
expect ( editor . props ( 'inquiry' ). recordVersion ). toBe ( 8 )
expect ( api . listCapacityInquiries ). not . toHaveBeenCalled ()
expect ( api . commandCapacityInquiry ). not . toHaveBeenCalled ()
})