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(), route: { query: {} as Record, 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', () => ({ 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 = (records: T[], total = records.length) => ({ records, total, page: 1, size: 20, pages: Math.ceil(total / 20) }) const wrappers: VueWrapper[] = [] const layout = { template: '
' } const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '' } function mount(component: object, props: Record = {}): { wrapper: VueWrapper; pinia: ReturnType } { 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'], emits: ['update:page'], template: '
' }, QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '
' }, CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '
' }, CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true, AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmText', 'beforeClose'], emits: ['update:modelValue', 'confirm'], template: '
' }, ElButton: { props: ['disabled', 'loading'], template: '' }, 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.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: '
' }, } } }) 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('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() expect(wrapper.text()).toContain('待调度核对') 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 })) await button(wrapper, '我的登记').trigger('click'); await flushPromises() 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>) => void vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done })) const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的登记').trigger('click'); await flushPromises() 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() await button(wrapper, '登记客户货物').trigger('click'); await flushPromises() 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() 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>).setProps({ modelValue: false }); await flushPromises() expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1) await (wrapper as VueWrapper>).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 } } const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的登记').trigger('click'); await flushPromises() 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>).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 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() expect(button(wrapper, '登记客户货物').attributes('disabled')).toBeDefined() expect(wrapper.text()).toContain('无法登记客户货物:交货截止已过') expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false) state.write = false const readonly = mount(CapacityOfferDetailView); await flushPromises() expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false) }) it('defaults minimal managers to their TODO inquiries without loading offers, and explicitly switches to available offers', async () => { state.manage = true const { wrapper } = mount(CapacityOffersView); await flushPromises() 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' })) 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() }) // 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() expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } }) dialog.vm.$emit('action', 'trip', row); await flushPromises() expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } }) 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() }) 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: '
' }, RowActions: { name: 'RowActions', props: ['actions'], template: '
' }, } } }) 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: '
' }, RowActions: { name: 'RowActions', props: ['actions', 'maxVisible'], template: '
' }, } } }) 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('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() }) 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([], 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() })