Files
admin-web/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
T

375 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
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'
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() }))
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
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) }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
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() }))
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 } }
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: ['预计重量'] } }
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 />' }
function mount(component: object, props: Record<string, unknown> = {}): { wrapper: VueWrapper; pinia: ReturnType<typeof createPinia> } {
const pinia = createPinia()
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
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>' },
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,
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmText', 'beforeClose'], emits: ['update:modelValue', 'confirm'], template: '<div v-if="modelValue"><slot/></div>' },
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)!
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)]) }) })
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
it('hides both legacy trip entries even for managers while retaining read-only offer detail', async () => {
state.manage = true
state.links.add('platform:fleet:trip:list')
const { wrapper: list } = mount(CapacityOffersView); await flushPromises()
expect(button(list, '选择车次发布余位')).toBeUndefined()
await button(list, '详情').trigger('click'); await flushPromises()
expect(state.push).toHaveBeenCalledExactlyOnceWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: offer.offerNo } })
state.push.mockClear()
const { wrapper: detail } = mount(CapacityOfferDetailView); await flushPromises()
expect(detail.text()).toContain(offer.tripNo)
expect(button(detail, offer.tripNo)).toBeUndefined()
expect(detail.text()).toContain(offer.contactPhone)
expect(state.push).not.toHaveBeenCalled()
})
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()
})
it('keeps the pool read-only and sends one query after resetting from a later page', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('待调度核对')
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
for (const label of ['我的登记', '待匹配散板', '登记客户货物', '待我处理']) expect(button(wrapper, label)).toBeUndefined()
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 }))
})
it('ignores an older pool query response and retains errors with retry', async () => {
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
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()
resolve(pageOf([offer], 900)); await flushPromises()
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()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
})
it('reads only the offer detail and hides registration even with write permission', async () => {
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
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)
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()
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()
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()
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()
})
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('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()
})
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()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ scope: 'ALL', tripNo: 'TR-1', page: 1, size: 1 })
})
it('retries an uncertain combined save and confirmation with the original payload', async () => {
state.manage = true
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
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)
})
it('keeps capacity blockers visible without registration prompts after cutoff', async () => {
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
expect(button(wrapper, '登记客户货物')).toBeUndefined()
expect(wrapper.text()).toContain('交货截止已过')
expect(wrapper.text()).not.toContain('无法登记客户货物')
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
})
it('defaults managers to the same read-only pool and preserves explicit historical queries', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
for (const label of ['我的登记', '待匹配散板', '待我处理', '登记客户货物']) expect(button(wrapper, label)).toBeUndefined()
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' }))
})
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()
})
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()
})
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(['安排来货'])
})
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]) }
})
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')
})
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('ignores old inquiry deep links and renders only the offer summary', async () => {
state.manage = true
state.route.query = { inquiry: inquiry.bookingNo }
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
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()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
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
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
expect(button(wrapper, '匹配车次')).toBeUndefined()
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
})