feat(admin): bind booking documents and truck containers
This commit is contained in:
@@ -54,7 +54,9 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: '通知人' },
|
||||
],
|
||||
containerRequirements: [{ requirementId: '31', containerType: '40HC', containerKind: 'FCL', quantity: 2, remainingQuantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
|
||||
containers: [], histories: [], statusHistory: [],
|
||||
containers: [],
|
||||
documents: [{ id: '41', oceanBookingId: '123', fileId: '51', documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: 128, contentType: 'application/pdf', valid: true, uploadedBy: '7', uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }],
|
||||
histories: [], statusHistory: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value)')
|
||||
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
||||
expect(source).not.toContain('releaseReason')
|
||||
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||
expect(source).toContain('OceanBookingDocumentsPanel')
|
||||
expect(source).toContain('@updated="updateDocuments"')
|
||||
})
|
||||
|
||||
it('freezes the selected carrier standard code into the booking draft', () => {
|
||||
@@ -105,6 +108,8 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('LoadErrorState')
|
||||
expect(source).toContain('@media(max-width:720px)')
|
||||
expect(source).toContain('@media(max-width:420px)')
|
||||
expect(source).toContain('OceanBookingPrintDialog')
|
||||
expect(source).toContain('打印单据')
|
||||
})
|
||||
|
||||
it('carries the existing read-only CarrierScheduleQuery on the booking detail page', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
|
||||
import { ArrowLeft, Plus, Printer, Save, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
OceanBookingAgentOption,
|
||||
OceanBookingCarrierCode,
|
||||
OceanBookingDraftData,
|
||||
OceanBookingDocument,
|
||||
OceanBookingParty,
|
||||
OceanBookingSaveRequest,
|
||||
OceanBookingStatus,
|
||||
@@ -41,6 +42,8 @@ import type {
|
||||
import { oceanBookingStatusLabel } from '../status'
|
||||
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
|
||||
import { shouldClearBookingAgent } from '../bookingAgents'
|
||||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||||
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
|
||||
|
||||
const partyTypes = [
|
||||
{ value: 'SHIPPER', label: '发货人' },
|
||||
@@ -103,8 +106,12 @@ 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 canPrint = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:list'))
|
||||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && Boolean(detail.value.customerId) && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : !detail.value?.customerId ? '请先选择客户并保存订舱草稿,再上传订舱资料' : '')
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value)
|
||||
const releaseDialogOpen = ref(false)
|
||||
const printOpen = ref(false)
|
||||
|
||||
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||||
return oceanBookingStatusLabel(status)
|
||||
@@ -303,8 +310,16 @@ function releaseValidationError() {
|
||||
if (missing) return `放舱前请填写${missing[1]}`
|
||||
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) return '预计到港时间不能早于预计开船时间'
|
||||
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
|
||||
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
|
||||
return containerRequirementError(form.data.containerRequirements)
|
||||
}
|
||||
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
|
||||
if (!detail.value) return
|
||||
detail.value.documents = documents
|
||||
detail.value.version = bookingVersion
|
||||
detail.value.oceanBooking.version = bookingVersion
|
||||
form.expectedVersion = bookingVersion
|
||||
}
|
||||
async function submitRelease() {
|
||||
if (!detail.value || workflowBusy.value) return
|
||||
actionSaving.value = true
|
||||
@@ -366,6 +381,7 @@ 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-tooltip :content="canPrint ? '选择出口货物托运单或提单确认件' : detail?.status === 'DRAFT' ? '订舱放舱后方可打印' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
|
||||
<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>
|
||||
@@ -445,6 +461,8 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
||||
|
||||
<div v-if="detail" class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.id" /></div>
|
||||
|
||||
<section v-if="detail" class="surface-panel history-panel">
|
||||
@@ -453,6 +471,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
|
||||
|
||||
<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">
|
||||
|
||||
@@ -35,6 +35,9 @@ describe('OceanBookingsView', () => {
|
||||
expect(source).toContain('<Pencil v-if="row.status === \'DRAFT\'"')
|
||||
expect(source).toContain("row.status === 'DRAFT' ? '编辑' : '查看'")
|
||||
expect(source).not.toContain('MoreHorizontal')
|
||||
expect(source).toContain('打印单据')
|
||||
expect(source).toContain('OceanBookingPrintDialog')
|
||||
expect(source).toContain("access.can('platform:firstleg:ocean-booking:list')")
|
||||
})
|
||||
|
||||
it('uses only the frozen DRAFT and RELEASED status options', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||
import { Eye, Pencil, Plus, Printer } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
@@ -15,6 +15,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listOceanBookings, type OceanBookingResource } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingQuery, OceanBookingStatus } from '../types'
|
||||
import { oceanBookingStatusLabel, oceanBookingStatusOptions } from '../status'
|
||||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -29,13 +30,16 @@ const customersLoading = ref(false)
|
||||
const query = reactive<QueryModel>({ customerId: '', keyword: '', status: '' })
|
||||
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
|
||||
const canCreate = computed(() => access.can('platform:firstleg:ocean-booking:create'))
|
||||
const canPrint = computed(() => access.can('platform:firstleg:ocean-booking:list'))
|
||||
const printOpen = ref(false)
|
||||
const printBooking = ref<{ id: string; bookingNo?: string | null } | null>(null)
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/提单号/船名/航次', width: 260 },
|
||||
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions },
|
||||
])
|
||||
const columns = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 100, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 196, slotName: 'actions', hideable: false, movable: false },
|
||||
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' },
|
||||
@@ -57,6 +61,8 @@ async function loadCustomers() { customersLoading.value = true; try { const resu
|
||||
async function load() { loading.value = true; error.value = null; try { const keyword = String(query.keyword ?? '').trim(); const customerId = String(query.customerId ?? '').trim(); const status = String(query.status ?? '').trim(); const result = await listOceanBookings({ page: page.value, size: pageSize.value, customerId: customerId || undefined, keyword: keyword || undefined, status: (status || undefined) as OceanBookingStatus | undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
|
||||
function open(id: string) { void router.push(`/ocean-bookings/${id}`) }
|
||||
function createBooking() { void router.push('/ocean-bookings/new') }
|
||||
function openPrint(row: Record<string, unknown>) { printBooking.value = { id: String(row.id), bookingNo: typeof row.bookingNo === 'string' ? row.bookingNo : null }; printOpen.value = true }
|
||||
function printHint(row: Record<string, unknown>) { return !canPrint.value ? '暂无订舱单据打印权限' : row.status !== 'RELEASED' ? '订舱放舱后方可打印' : '选择出口货物托运单或提单确认件' }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
watch([page, pageSize], () => void load()); onMounted(() => void loadCustomers())
|
||||
onActivated(() => void load())
|
||||
@@ -69,7 +75,7 @@ onActivated(() => void load())
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button><el-tooltip :content="printHint(row)" placement="top"><span><el-button size="small" plain :disabled="!canPrint || row.status !== 'RELEASED'" @click="openPrint(row)"><Printer :size="15" />打印单据</el-button></span></el-tooltip></div></template>
|
||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
|
||||
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
|
||||
@@ -78,6 +84,7 @@ onActivated(() => void load())
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<OceanBookingPrintDialog v-if="printBooking" v-model="printOpen" :booking-id="printBooking.id" :booking-no="printBooking.bookingNo" :allowed="canPrint" />
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>.ocean-bookings-page{min-width:0}.table-actions{display:flex;align-items:center;flex-wrap:wrap;gap:6px}.table-actions :deep(.el-button){margin-left:0}@media(max-width:420px){.table-actions{flex-direction:column;align-items:stretch}.table-actions :deep(.el-button){width:100%;min-height:40px}}</style>
|
||||
|
||||
Reference in New Issue
Block a user