fix: align audit and dispatch workflows and release failed requests

This commit is contained in:
2026-10-22 23:10:09 +08:00
parent 9cdcb9c077
commit 4a3456756f
124 changed files with 1296 additions and 2120 deletions
@@ -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">