fix: align audit and dispatch workflows and release failed requests
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
releaseOceanBooking,
|
||||
updateOceanBookingDraft,
|
||||
updateOceanBookingActualCargo,
|
||||
updateOceanBookingEstimatedArrival,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
|
||||
import OceanBookingDetailView from './OceanBookingDetailView.vue'
|
||||
@@ -32,7 +33,7 @@ vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformOceanBookings', () => ({
|
||||
createOceanBooking: vi.fn(), deleteOceanBookingDraft: vi.fn(), getOceanBooking: vi.fn(),
|
||||
listOceanBookingAgents: vi.fn(), listWarehouseLogisticsChannels: vi.fn(),
|
||||
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(),
|
||||
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(), updateOceanBookingEstimatedArrival: vi.fn(),
|
||||
}))
|
||||
|
||||
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
|
||||
@@ -65,6 +66,12 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
|
||||
}
|
||||
|
||||
type ViewModel = {
|
||||
arrivalValue: string | null
|
||||
arrivalDialogOpen: boolean
|
||||
arrivalSaving: boolean
|
||||
arrivalError: { message: string; code?: string } | null
|
||||
openArrivalDialog: () => void
|
||||
saveEstimatedArrival: () => Promise<void>
|
||||
form: { data: import('../types').OceanBookingDraftData }
|
||||
saveActualCargo: () => Promise<void>
|
||||
actualReason: string
|
||||
@@ -96,6 +103,63 @@ async function mountView() {
|
||||
}
|
||||
|
||||
describe('OceanBookingDetailView one-click release', () => {
|
||||
it('放舱后仅单独保存预计到港并立即回显,保留尚未提交的实货量', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
const saved = { ...booking('RELEASED', 4), estimatedArrivalAt: '2026-09-25T08:00:00' }
|
||||
vi.mocked(updateOceanBookingEstimatedArrival).mockResolvedValue(saved)
|
||||
const { wrapper, vm } = await mountView()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(true)
|
||||
vm.form.data.actualPackageCount = 499
|
||||
vm.actualReason = '尚未提交'
|
||||
vm.openArrivalDialog()
|
||||
vm.arrivalValue = saved.estimatedArrivalAt
|
||||
await vm.saveEstimatedArrival()
|
||||
expect(updateOceanBookingEstimatedArrival).toHaveBeenCalledExactlyOnceWith('123', { expectedVersion: 3, estimatedArrivalAt: saved.estimatedArrivalAt })
|
||||
expect(vm.detail?.estimatedArrivalAt).toBe(saved.estimatedArrivalAt)
|
||||
expect(vm.detail?.version).toBe(4)
|
||||
expect(vm.form.data.actualPackageCount).toBe(499)
|
||||
expect(vm.actualReason).toBe('尚未提交')
|
||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||
const coreForm = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('booking-groups'))!
|
||||
expect(coreForm.props('disabled')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('无编辑权限不开放维护入口;版本冲突保留输入并恢复操作', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
deniedPermissions.add('platform:firstleg:ocean-booking:edit')
|
||||
let rendered = await mountView()
|
||||
expect(rendered.wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(false)
|
||||
rendered.vm.openArrivalDialog()
|
||||
await rendered.vm.saveEstimatedArrival()
|
||||
expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled()
|
||||
rendered.wrapper.unmount()
|
||||
deniedPermissions.clear()
|
||||
rendered = await mountView()
|
||||
rendered.vm.openArrivalDialog()
|
||||
rendered.vm.arrivalValue = '2026-09-25T08:00:00'
|
||||
vi.mocked(updateOceanBookingEstimatedArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化,请刷新后重试', 'A0409'))
|
||||
await rendered.vm.saveEstimatedArrival()
|
||||
expect(rendered.vm.arrivalDialogOpen).toBe(true)
|
||||
expect(rendered.vm.arrivalValue).toBe('2026-09-25T08:00:00')
|
||||
expect(rendered.vm.arrivalSaving).toBe(false)
|
||||
expect(rendered.vm.arrivalError?.code).toBe('A0409')
|
||||
rendered.wrapper.unmount()
|
||||
})
|
||||
|
||||
it('放舱后预计到港不得清空或早于预计开船', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
const { wrapper, vm } = await mountView()
|
||||
vm.openArrivalDialog()
|
||||
for (const value of [null, '2026-08-01T08:00:00']) {
|
||||
vm.arrivalValue = value
|
||||
await vm.saveEstimatedArrival()
|
||||
expect(vm.arrivalError?.message).toBeTruthy()
|
||||
}
|
||||
expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123'
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('if (!saved) return')
|
||||
expect(source).toContain("{{ form.data.billOfLadingNo || '—' }}")
|
||||
expect(source).not.toContain("{{ detail?.billOfLadingNo || '—' }}")
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)')
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)')
|
||||
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
||||
expect(source).not.toContain('releaseReason')
|
||||
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
listWarehouseLogisticsChannels,
|
||||
updateOceanBookingDraft,
|
||||
updateOceanBookingActualCargo,
|
||||
updateOceanBookingEstimatedArrival,
|
||||
type OceanLogisticsChannel,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type {
|
||||
@@ -115,9 +116,14 @@ const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:
|
||||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
|
||||
const actualSaving = ref(false)
|
||||
const arrivalDialogOpen = ref(false)
|
||||
const arrivalSaving = ref(false)
|
||||
const arrivalValue = ref<string | null>(null)
|
||||
const arrivalError = ref<ApiErrorDetails | null>(null)
|
||||
const canMaintainArrival = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const actualReason = ref('')
|
||||
const cargoWritable = computed(() => formWritable.value || (detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit')))
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)
|
||||
const releaseDialogOpen = ref(false)
|
||||
const operationSections = ref<string[]>([])
|
||||
const printOpen = ref(false)
|
||||
@@ -128,7 +134,7 @@ function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||||
return oceanBookingStatusLabel(status)
|
||||
}
|
||||
function historyActionLabel(action: string) {
|
||||
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
|
||||
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ESTIMATED_ARRIVAL: '维护预计到港', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
|
||||
return labels[action] || '资料维护'
|
||||
}
|
||||
async function loadOptions() {
|
||||
@@ -185,6 +191,35 @@ function openPrint(type: OceanBookingPrintType) {
|
||||
printType.value = type
|
||||
printOpen.value = true
|
||||
}
|
||||
function openArrivalDialog() {
|
||||
if (!canMaintainArrival.value || workflowBusy.value) return
|
||||
arrivalValue.value = detail.value?.estimatedArrivalAt || null
|
||||
arrivalError.value = null
|
||||
arrivalDialogOpen.value = true
|
||||
}
|
||||
async function saveEstimatedArrival() {
|
||||
const current = detail.value
|
||||
if (!current || !canMaintainArrival.value || workflowBusy.value) return
|
||||
if (!arrivalValue.value || (current.estimatedSailingAt && arrivalValue.value < current.estimatedSailingAt)) {
|
||||
arrivalError.value = { message: !arrivalValue.value ? '请选择预计到港时间' : '预计到港时间不能早于预计开船时间' }
|
||||
return
|
||||
}
|
||||
arrivalSaving.value = true
|
||||
arrivalError.value = null
|
||||
try {
|
||||
const saved = await updateOceanBookingEstimatedArrival(current.id, { expectedVersion: current.version, estimatedArrivalAt: arrivalValue.value })
|
||||
// 单独保存预计到港不丢弃客服尚未提交的实际货量。
|
||||
const pendingCargo = { actualPackageCount: form.data.actualPackageCount, actualGrossWeightKg: form.data.actualGrossWeightKg, actualVolumeCbm: form.data.actualVolumeCbm }
|
||||
const pendingReason = actualReason.value
|
||||
applyDetail(saved)
|
||||
Object.assign(form.data, pendingCargo)
|
||||
actualReason.value = pendingReason
|
||||
workspace.bumpCacheEpoch('ocean-bookings')
|
||||
arrivalDialogOpen.value = false
|
||||
ElMessage.success('预计到港已保存')
|
||||
} catch (cause) { arrivalError.value = getApiErrorDetails(cause, '预计到港保存失败') }
|
||||
finally { arrivalSaving.value = false }
|
||||
}
|
||||
async function saveActualCargo() {
|
||||
if (detail.value?.status !== 'RELEASED' || !cargoWritable.value || workflowBusy.value) return
|
||||
const validationError = cargoValidationError()
|
||||
@@ -451,6 +486,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
|
||||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||||
<el-button v-if="canSave" plain :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
|
||||
<el-button v-if="canMaintainArrival" plain :disabled="workflowBusy" @click="openArrivalDialog">维护预计到港</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</PageToolbar>
|
||||
@@ -614,6 +650,10 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
<AppDialog v-if="detail?.status === 'RELEASED'" v-model="arrivalDialogOpen" class="admin-density-dialog admin-workbench is-dialog" :title="bookingDialogTitle('维护预计到港')" width="560px" :confirm-loading="arrivalSaving" :confirm-disabled="!canMaintainArrival" confirm-text="保存" @confirm="saveEstimatedArrival">
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="预计到港" required><el-date-picker v-model="arrivalValue" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item></el-form>
|
||||
<LoadErrorState v-if="arrivalError" compact title="预计到港保存失败" :message="arrivalError.message" :show-retry="false"><template #details><ApiErrorMeta :details="arrivalError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
|
||||
Reference in New Issue
Block a user