feat(appointments): simplify planning and preserve source warehouse intent

This commit is contained in:
2026-10-10 10:44:07 +08:00
parent fb062b5ec4
commit 901431f17a
26 changed files with 423 additions and 108 deletions
@@ -142,6 +142,7 @@
| 客户货物登记 | 新增、补充资料、只读跟进 | 1080px居中三列 | 首屏客户/目的地/已知货量;确认前必填折叠但显示缺项;始终选填独立;单站自动带入 |
| 客户货物登记 | 确认承运、取消 | 既有居中确认框 | 展示对象及保位/商业订单影响,保存成功不冒充确认成功 |
| 客户货物登记 | 安排来货、核量、排入本车 | 1080px居中,复用履约表单 | 原登记连续处理,按服务端资格;关联业务按权限跳转 |
| 客户货物登记 | 查看排车详情、前往车次审核 | 只读跟进弹框跳转既有独立详情 | 排车与车次分别按各自list权限展示;审核仍由车次独立写权限控制,不在排车只读页暗示可审核;待TEST发布后1920×1080复验 |
| 车次准备 | 选择合格排车、新建或加入车次、连续发布 | 1080px居中,复用车次与发布表单 | 来源带入、版本和失败续办,未授权的预约事实不由此生成 |
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
@@ -125,7 +125,8 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<el-button v-if="inquiry.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-confirm')" @click="emit('action', 'receipt', inquiry)">核对实收与运费</el-button>
<el-button v-if="inquiry.progress.primaryAction === 'BIND' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch')" @click="emit('action', 'bind', inquiry)">确认排入本车</el-button>
<el-button v-if="inquiry.incomingNo" @click="emit('action', 'fulfillment', inquiry)">查看收货进度</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('action', 'dispatch', inquiry)">查看排车与审核</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('action', 'dispatch', inquiry)">查看排车详情</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:fleet:trip:list')" @click="emit('action', 'trip', inquiry)">前往车次审核</el-button>
</section>
<section v-if="inquiry?.appointmentCoverage" aria-label="收货预约覆盖">
<h3>收货预约覆盖</h3><el-button v-if="access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')" @click="emit('action', 'appointment', inquiry)">预约跟进</el-button><p>{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。</p>
@@ -23,6 +23,7 @@ const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>
function openFulfillment(key: string, row: CapacityInquiry) {
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); return true }
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
editorOpen.value = false
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
@@ -10,9 +10,9 @@ 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, route: { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, links: new Set<string>(), route: { 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) => 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('@/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 } }
@@ -35,7 +35,7 @@ function mount(component: object, props: Record<string, unknown> = {}) {
} } }); wrappers.push(wrapper); return { wrapper, pinia }
}
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; 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)]) }) })
beforeEach(() => { 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('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
@@ -161,3 +161,33 @@ it('requires an explicit choice for multiple destinations', async () => {
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' } })
dialog.vm.$emit('action', 'trip', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })
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()
})
@@ -26,6 +26,7 @@ const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>
function openFulfillment(key: string, row: CapacityInquiry) {
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); return true }
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
editorOpen.value = false
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true