fix(admin): save dirty ocean booking before release

This commit is contained in:
2026-08-31 14:52:38 +08:00
parent 4bf937ffd3
commit dc1027804e
3 changed files with 225 additions and 46 deletions
@@ -97,11 +97,13 @@ function blankData(): OceanBookingDraftData {
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const savedDraftSignature = ref('')
const rules: FormRules = {}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit'))
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
const workflowBusy = computed(() => saving.value || actionSaving.value)
const releaseDialogOpen = ref(false)
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
@@ -136,16 +138,25 @@ function selectCustomer(value: unknown) {
const customer = customerId == null ? null : customers.value.find((item) => String(item.id) === customerId)
form.data.customerName = customer?.tenantName || null
}
function normalizeDraftData() {
form.data.customerId = form.data.customerId || null
form.data.customerName = form.data.customerId ? form.data.customerName : null
form.data.serviceTerm = form.data.serviceTerm?.trim() || null
form.data.freightTerm = form.data.freightTerm?.trim() || null
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
form.data.vesselName = form.data.vesselName.trim()
form.data.voyageNo = form.data.voyageNo.trim()
form.data.containerRequirements.forEach((requirement) => { requirement.quantity = Number(requirement.quantity) })
function draftPayload(): OceanBookingDraftData {
const customerId = form.data.customerId || null
return {
...form.data,
customerId,
customerName: customerId ? form.data.customerName : null,
channelCode: form.data.channelCode?.trim() || null,
warehouseId: form.data.warehouseId?.trim() || null,
serviceTerm: form.data.serviceTerm?.trim() || null,
freightTerm: form.data.freightTerm?.trim() || null,
billOfLadingNo: form.data.billOfLadingNo.trim(),
vesselName: form.data.vesselName.trim(),
voyageNo: form.data.voyageNo.trim(),
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity: Number(quantity), capacityCbm, maxWeightKg })),
}
}
function draftSignature() { return JSON.stringify(draftPayload()) }
const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value))
function selectWarehouse(value: unknown) {
const warehouseId = value == null || value === '' ? null : String(value)
form.data.warehouseId = warehouseId
@@ -229,6 +240,7 @@ function applyDetail(value: OceanBookingDetail) {
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
}
selectedChannelName.value = value.channelName || ''
savedDraftSignature.value = draftSignature()
channels.value = []
if (form.data.warehouseId) void loadChannels(form.data.warehouseId)
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
@@ -241,47 +253,45 @@ async function load() {
catch (cause) { error.value = getApiErrorDetails(cause) }
finally { loading.value = false }
}
async function save() {
if (!canSave.value) return
actionError.value = null
normalizeDraftData()
async function validateDraftForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (valid === false) { actionError.value = { message: '请检查表单中的必填项和格式' }; return false }
const requirementMessage = containerRequirementError(form.data.containerRequirements)
if (requirementMessage) { actionError.value = { message: requirementMessage }; return }
saving.value = true
if (requirementMessage) { actionError.value = { message: requirementMessage }; return false }
return true
}
async function persistDraft(showSuccess: boolean) {
if (!canSave.value || !(await validateDraftForm())) return null
actionError.value = null
try {
const payload = {
...form.data,
channelCode: form.data.channelCode?.trim() || null,
warehouseId: form.data.warehouseId?.trim() || null,
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity, capacityCbm, maxWeightKg })),
}
const payload = draftPayload()
const saved = isNew.value
? await createOceanBooking({ expectedVersion: 0, data: payload })
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || saved.id}` : '订舱草稿已保存')
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || saved.id}` : '订舱草稿已保存')
if (isNew.value) {
workspace.removeByIds(['ocean-booking-create'])
await router.replace('/ocean-bookings')
return
return saved
}
applyDetail(saved)
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
return saved
} catch (cause) { actionError.value = getApiErrorDetails(cause); return null }
}
async function save() {
if (!canSave.value || workflowBusy.value) return
saving.value = true
try { await persistDraft(true) }
finally { saving.value = false }
}
function openReleaseDialog() {
normalizeDraftData()
async function openReleaseDialog() {
if (!canRelease.value || workflowBusy.value || !(await validateDraftForm())) return
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; return }
releaseDialogOpen.value = true
}
function releaseValidationError() {
if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'
if (detail.value && (detail.value.carrierCode !== form.data.carrierCode
|| String(detail.value.shippingCompanyId || '') !== String(form.data.shippingCompanyId || ''))) {
return '船公司标准编码尚未保存,请先保存草稿后再放舱'
}
const requiredFields: Array<[unknown, string]> = [
[form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
[form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'],
@@ -296,13 +306,20 @@ function releaseValidationError() {
return containerRequirementError(form.data.containerRequirements)
}
async function submitRelease() {
if (!detail.value) return
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return }
if (!detail.value || workflowBusy.value) return
actionSaving.value = true
actionError.value = null
try {
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version }))
if (!(await validateDraftForm())) { releaseDialogOpen.value = false; return }
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return }
let releaseVersion = detail.value.version
if (hasUnsavedDraftChanges.value) {
const saved = await persistDraft(false)
if (!saved) return
releaseVersion = saved.version
}
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: releaseVersion }))
releaseDialogOpen.value = false
ElMessage.success('确认放舱成功')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
@@ -349,9 +366,9 @@ onMounted(() => { void load(); void loadOptions() })
<div class="section-title">
<div><h2>订舱信息</h2><p v-if="detail?.status === 'DRAFT'">草稿可反复保存;放舱后资料同步至关联货柜并锁定。</p><p v-else-if="detail">已放舱资料只读。</p></div>
<div class="actions">
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" @click="openReleaseDialog">放舱</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存草稿</el-button>
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
@@ -437,15 +454,15 @@ onMounted(() => { void load(); void loadOptions() })
</template>
</DetailPageLayout>
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<el-form label-position="top">
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。" type="warning" :closable="false" show-icon />
<el-descriptions class="release-summary" :column="2" border>
<el-descriptions-item label="提单号">{{ detail?.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ detail?.vesselName || '—' }}</el-descriptions-item>
<el-descriptions-item label="航次">{{ detail?.voyageNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计开船">{{ detail?.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ detail?.estimatedArrivalAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
<el-descriptions-item label="航次">{{ form.data.voyageNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计开船">{{ form.data.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ form.data.estimatedArrivalAt || '—' }}</el-descriptions-item>
</el-descriptions>
</el-form>
</AppDialog>