feat(admin): streamline appointments and fleet intake

This commit is contained in:
2026-10-09 22:05:51 +08:00
parent 88dc4852c6
commit e1457a8e57
39 changed files with 1215 additions and 165 deletions
@@ -14,9 +14,9 @@ const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, rout
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => 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(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
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 } }
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>' }
@@ -44,7 +44,7 @@ it('loads only the active tab and sends exactly one query after resetting from a
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()
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' }))
@@ -52,7 +52,7 @@ it('loads only the active tab and sends exactly one query after resetting from a
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
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()
@@ -62,7 +62,7 @@ it('ignores an older page response after changing tabs and retains an explicit e
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()
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()
})
@@ -100,7 +100,7 @@ it('retains an uncertain request and route protection after closing and reopenin
})
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()
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()
})
@@ -110,27 +110,31 @@ it('loads publishing settings only when explicitly opened, not with the trip pag
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 dispatch confirmation with the same command and no second confirmation box', async () => {
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.commandCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: 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()
const first = vi.mocked(api.commandCapacityInquiry).mock.calls[0]!
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
expect(api.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
expect(api.listCapacityInquiries).toHaveBeenCalledOnce()
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(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)
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
})
it('defaults administrators to available offers and requires an explicit history filter', async () => {
state.manage = true
@@ -142,3 +146,18 @@ it('defaults administrators to available offers and requires an explicit history
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()
})