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-20 21:33:46 +08:00
vi . mock ( '../api/capacityOffers' , () => ({ listLooseboardDemands : vi.fn (), 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-21 18:05:02 +08:00
DataTable : { name : 'DataTable' , props : [ 'data' , 'page' , 'pageSize' , 'total' , 'columns' ], emits : [ 'update:page' ], template : '<div><slot name="toolbar-start"/><slot name="toolbar-end"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="actions" :row="row"/><slot name="loads" :row="row"/><slot name="status" :row="row"/><slot name="contact" :row="row"/><slot name="destinations" :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-21 18:05:02 +08:00
it ( 'keeps the pool read-only and sends one query after resetting from a later page' , async () => {
2026-10-07 16:46:57 +08:00
const { wrapper } = mount ( CapacityOffersView ); await flushPromises ()
2026-10-21 18:05:02 +08:00
expect ( api . listCapacityOffers ). toHaveBeenCalledExactlyOnceWith ( expect . objectContaining ({ scope : 'AVAILABLE' }))
expect ( api . listCapacityInquiries ). not . toHaveBeenCalled ()
expect ( api . listLooseboardDemands ). not . toHaveBeenCalled ()
2026-10-07 18:21:03 +08:00
expect ( wrapper . text ()). toContain ( '待调度核对' )
2026-10-21 18:05:02 +08:00
expect ( wrapper . findComponent ( CapacityInquiryDialog ). exists ()). toBe ( false )
expect ( wrapper . findComponent ({ name : 'CapacityInquiryFulfillment' }). exists ()). toBe ( false )
for ( const label of [ '我的登记' , '待匹配散板' , '登记客户货物' , '待我处理' ]) expect ( button ( wrapper , label )). toBeUndefined ()
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 ()
2026-10-21 18:05:02 +08:00
expect ( api . listCapacityOffers ). toHaveBeenCalledTimes ( 3 )
expect ( api . listCapacityOffers ). toHaveBeenLastCalledWith ( expect . objectContaining ({ page : 1 }))
2026-10-07 16:46:57 +08:00
})
2026-10-21 18:05:02 +08:00
it ( 'ignores an older pool query response and retains errors with retry' , async () => {
2026-10-07 16:46:57 +08:00
let resolve !: ( value : ReturnType < typeof pageOf < CapacityOffer >>) => void
2026-10-21 18:05:02 +08:00
vi . mocked ( api . listCapacityOffers ). mockReturnValueOnce ( new Promise ( done => { resolve = done }))
const { wrapper } = mount ( CapacityOffersView )
vi . mocked ( api . listCapacityOffers ). mockResolvedValue ( pageOf ([ offer ], 7 ))
wrapper . findComponent ({ name : 'QueryToolbar' }). vm . $emit ( 'search' ); await flushPromises ()
2026-10-07 16:46:57 +08:00
resolve ( pageOf ([ offer ], 900 )); await flushPromises ()
2026-10-21 18:05:02 +08:00
expect ( wrapper . findComponent ({ name : 'DataTable' }). props ( 'total' )). toBe ( 7 )
vi . mocked ( api . listCapacityOffers ). mockRejectedValueOnce ( new Error ( '读取失败' ))
wrapper . findComponent ({ name : 'QueryToolbar' }). vm . $emit ( 'search' ); await flushPromises ()
2026-10-07 16:46:57 +08:00
expect ( wrapper . findComponent ({ name : 'LoadErrorState' }). exists ()). toBe ( true )
2026-10-21 18:05:02 +08:00
wrapper . findComponent ({ name : 'LoadErrorState' }). vm . $emit ( 'retry' ); await flushPromises ()
expect ( wrapper . findComponent ({ name : 'LoadErrorState' }). exists ()). toBe ( false )
2026-10-07 16:46:57 +08:00
})
2026-10-21 18:05:02 +08:00
it ( 'reads only the offer detail and hides registration even with write permission' , async () => {
2026-10-07 16:46:57 +08:00
const { wrapper } = mount ( CapacityOfferDetailView ); await flushPromises ()
2026-10-21 18:05:02 +08:00
expect ( api . getCapacityOffer ). toHaveBeenCalledOnce ()
expect ( api . listCapacityInquiries ). not . toHaveBeenCalled ()
expect ( button ( wrapper , '登记客户货物' )). toBeUndefined ()
expect ( wrapper . findComponent ( CapacityInquiryDialog ). exists ()). toBe ( false )
expect ( wrapper . findComponent ({ name : 'CapacityInquiryTable' }). exists ()). toBe ( false )
2026-10-07 16:46:57 +08:00
expect ( api . listOfferCustomers ). not . toHaveBeenCalled (); expect ( api . listOfferQuotes ). not . toHaveBeenCalled ()
})
2026-10-21 18:05:02 +08:00
2026-10-07 16:46:57 +08:00
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 )
})
2026-10-21 18:05:02 +08:00
it ( 'retains pool route details as the only cargo row action for sales' , async () => {
const { wrapper } = mount ( CapacityOffersView ); await flushPromises ()
expect ( wrapper . findComponent ({ name : 'CapacityInquiryTable' }). exists ()). toBe ( false )
expect ( api . commandCapacityInquiry ). not . toHaveBeenCalled ()
expect ( ElMessageBox . confirm ). not . toHaveBeenCalled ()
2026-10-07 16:46:57 +08:00
})
2026-10-21 18:05:02 +08:00
2026-10-07 16:46:57 +08:00
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
2026-10-21 18:05:02 +08:00
it ( 'keeps capacity blockers visible without registration prompts after cutoff' , async () => {
2026-10-08 14:11:54 +08:00
vi . mocked ( api . getCapacityOffer ). mockResolvedValue ({ offer : { ... offer , canCreateInquiry : false , blockReason : '交货截止已过或不符合当前发车时间' }, inquiries : pageOf ([]) })
const { wrapper } = mount ( CapacityOfferDetailView ); await flushPromises ()
2026-10-21 18:05:02 +08:00
expect ( button ( wrapper , '登记客户货物' )). toBeUndefined ()
expect ( wrapper . text ()). toContain ( '交货截止已过' )
expect ( wrapper . text ()). not . toContain ( '无法登记客户货物' )
expect ( wrapper . findComponent ( CapacityInquiryDialog ). exists ()). toBe ( false )
2026-10-08 14:11:54 +08:00
})
2026-10-21 18:05:02 +08:00
it ( 'defaults managers to the same read-only pool and preserves explicit historical queries' , 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 . listCapacityOffers ). toHaveBeenCalledExactlyOnceWith ( expect . objectContaining ({ scope : 'AVAILABLE' }))
2026-10-21 18:05:02 +08:00
expect ( api . listCapacityInquiries ). not . toHaveBeenCalled ()
expect ( api . listLooseboardDemands ). not . toHaveBeenCalled ()
for ( const label of [ '我的登记' , '待匹配散板' , '待我处理' , '登记客户货物' ]) expect ( button ( wrapper , label )). toBeUndefined ()
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
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 ])
})
2026-10-21 18:05:02 +08:00
it ( 'ignores old inquiry deep links and renders only the offer summary' , async () => {
2026-10-11 15:33:09 +08:00
state . manage = true
2026-10-11 19:33:25 +08:00
state . route . query = { inquiry : inquiry.bookingNo }
const { wrapper } = mount ( CapacityOfferDetailView ); await flushPromises ()
2026-10-21 18:05:02 +08:00
expect ( api . getCapacityOffer ). toHaveBeenCalledExactlyOnceWith ( 'CO-1' )
expect ( api . getCapacityInquiry ). not . toHaveBeenCalled ()
expect ( wrapper . findComponent ( CapacityInquiryDialog ). exists ()). toBe ( false )
expect ( wrapper . findComponent ({ name : 'CapacityInquiryFulfillment' }). exists ()). toBe ( false )
expect ( wrapper . text ()). toContain ( offer . warehouseName )
expect ( wrapper . text ()). toContain ( offer . contactPhone )
})
it ( 'keeps transport, destination, remaining capacity and contact columns for manual follow-up' , async () => {
const { wrapper } = mount ( CapacityOffersView ); await flushPromises ()
const columns = wrapper . findComponent ({ name : 'DataTable' }). props ( 'columns' ) as { key : string }[]
for ( const key of [ 'warehouseName' , 'destinations' , 'plannedDepartureAt' , 'contactName' ]) expect ( columns . some ( column => column . key === key )). toBe ( true )
expect ( api . saveCapacityInquiry ). not . toHaveBeenCalled ()
2026-10-11 19:33:25 +08:00
expect ( api . commandCapacityInquiry ). not . toHaveBeenCalled ()
})
2026-10-20 21:33:46 +08:00
2026-10-21 18:05:02 +08:00
it ( 'refreshes and retries read-only offer detail without loading an old inquiry' , async () => {
state . route . query = { inquiry : inquiry.bookingNo }
const { wrapper } = mount ( CapacityOfferDetailView ); await flushPromises ()
vi . mocked ( api . getCapacityOffer ). mockClear ()
await button ( wrapper , '重新核对' ). trigger ( 'click' ); await flushPromises ()
expect ( api . getCapacityOffer ). toHaveBeenCalledExactlyOnceWith ( 'CO-1' )
expect ( api . getCapacityInquiry ). not . toHaveBeenCalled ()
vi . mocked ( api . getCapacityOffer ). mockRejectedValueOnce ( new Error ( '读取失败' ))
await button ( wrapper , '重新核对' ). trigger ( 'click' ); await flushPromises ()
const error = wrapper . findComponent ({ name : 'LoadErrorState' })
expect ( error . exists ()). toBe ( true )
error . vm . $emit ( 'retry' ); await flushPromises ()
expect ( wrapper . findComponent ({ name : 'LoadErrorState' }). exists ()). toBe ( false )
expect ( api . getCapacityInquiry ). not . toHaveBeenCalled ()
expect ( api . commandCapacityInquiry ). not . toHaveBeenCalled ()
})
it ( 'does not load customer looseboard demands or expose matching from the pool' , async () => {
state . manage = true
2026-10-20 21:33:46 +08:00
const { wrapper } = mount ( CapacityOffersView ); await flushPromises ()
2026-10-21 18:05:02 +08:00
expect ( api . listLooseboardDemands ). not . toHaveBeenCalled ()
expect ( button ( wrapper , '匹配车次' )). toBeUndefined ()
expect ( wrapper . findComponent ( CapacityInquiryDialog ). exists ()). toBe ( false )
2026-10-20 21:33:46 +08:00
})