Release/test 合并上生产 #2
@@ -6,6 +6,7 @@ import {
|
||||
getContainerClearance, uploadContainerClearance, completeContainerClearance,
|
||||
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||
getCustomerContainerTransitSummary,
|
||||
createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking,
|
||||
updateContainer, updateContainerTransport,
|
||||
} from './platformContainerLoading'
|
||||
|
||||
@@ -46,6 +47,17 @@ describe('platform container loading API', () => {
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/containers/7/transport', { version: 3, containerNo: 'MSCU1', voyageNo: 'V8' })
|
||||
})
|
||||
|
||||
it('uses the frozen simulation create and ocean booking link contracts', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } })
|
||||
await createContainerSimulation({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 })
|
||||
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', expectedVersion: 3 })
|
||||
await unlinkContainerOceanBooking('7', 4, '空柜改配')
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 })
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', expectedVersion: 3 })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
|
||||
})
|
||||
|
||||
it('normalizes every long id in candidates and plans', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', data: container })
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
ContainerPage,
|
||||
ContainerQuery,
|
||||
ContainerSaveRequest,
|
||||
ContainerSimulationCreateRequest,
|
||||
ContainerOceanBookingLinkRequest,
|
||||
SmartLoadingCandidate,
|
||||
SmartLoadingItem,
|
||||
SmartLoadingPlan,
|
||||
@@ -68,6 +70,9 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
id: id(raw.id),
|
||||
planId: optionalId(raw.planId),
|
||||
warehouseId: id(raw.warehouseId),
|
||||
oceanBookingId: optionalId(raw.oceanBookingId),
|
||||
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
|
||||
customerId: optionalId(raw.customerId),
|
||||
shippingCompanyId: optionalId(raw.shippingCompanyId),
|
||||
version: numberValue(raw.version),
|
||||
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
|
||||
@@ -149,11 +154,21 @@ export function downloadContainerPdaPhoto(downloadPath: string) {
|
||||
return http.get<Blob, Blob>(downloadPath, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export async function createContainer(payload: ContainerSaveRequest) {
|
||||
export async function createContainerSimulation(payload: ContainerSimulationCreateRequest) {
|
||||
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(containerPath, payload)
|
||||
return container(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function linkContainerOceanBooking(containerId: string, payload: ContainerOceanBookingLinkRequest) {
|
||||
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/ocean-booking-link`, payload)
|
||||
return container(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function unlinkContainerOceanBooking(containerId: string, expectedVersion: number, reason: string) {
|
||||
const response = await http.delete<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/ocean-booking-link`, { data: { expectedVersion, reason } })
|
||||
return container(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function updateContainer(containerId: string, version: number, data: ContainerSaveRequest) {
|
||||
const response = await http.put<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`, { version, data })
|
||||
return container(unwrapApiResponse(response))
|
||||
|
||||
@@ -226,10 +226,24 @@ describe('ContainersView', () => {
|
||||
expect(source).not.toContain('上传入口待')
|
||||
})
|
||||
|
||||
it('does not expose manual container creation without an ocean-booking channel snapshot', () => {
|
||||
it('creates an internal simulation without asking for real booking transport facts', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).not.toContain('新增货柜')
|
||||
expect(source).not.toContain('createContainer(')
|
||||
expect(source).toContain('新增模拟柜')
|
||||
expect(source).toContain('createContainerSimulation({ ...simulationForm })')
|
||||
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000")
|
||||
expect(source).toContain('服务端会生成稳定的内部柜次')
|
||||
expect(source).not.toContain('openCreate')
|
||||
})
|
||||
|
||||
it('allows first linking after preloading but only offers unlinking for wait-loading containers', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain("!row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)")
|
||||
expect(source).toContain("row.oceanBookingId && row.planStatus === 'WAIT_LOADING'")
|
||||
expect(source).toContain('已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联')
|
||||
expect(source).toContain('内部柜次与装柜明细保持不变')
|
||||
expect(source).toContain('只有尚未排货的空柜可以解除')
|
||||
expect(source).toContain('linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })')
|
||||
expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, ListChecks, MoreHorizontal, Pencil, Sparkles, TriangleAlert } from '@lucide/vue'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert, Unlink } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingDetail, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -19,8 +19,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import { cancelContainerException, completeContainerClearance, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest } from '../types'
|
||||
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, unlinkContainerOceanBooking, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
|
||||
|
||||
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
|
||||
@@ -74,6 +74,22 @@ const editorOpen = ref(false)
|
||||
const editorError = ref<ApiErrorDetails | null>(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const simulationOpen = ref(false)
|
||||
const simulationSaving = ref(false)
|
||||
const simulationError = ref<ApiErrorDetails | null>(null)
|
||||
const simulationChannels = ref<OceanLogisticsChannel[]>([])
|
||||
const simulationChannelsLoading = ref(false)
|
||||
const simulationFormRef = ref<FormInstance>()
|
||||
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 })
|
||||
const linkOpen = ref(false)
|
||||
const linkLoading = ref(false)
|
||||
const linkSaving = ref(false)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkTarget = ref<ContainerBookingResource | null>(null)
|
||||
const linkCandidateKey = ref('')
|
||||
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; detail: OceanBookingDetail }>>([])
|
||||
const unlinkOpen = ref(false)
|
||||
const unlinkReason = ref('')
|
||||
let listSequence = 0
|
||||
let detailSequence = 0
|
||||
|
||||
@@ -97,6 +113,7 @@ const rules: FormRules<ContainerForm> = {
|
||||
|
||||
const canView = computed(() => access.can('platform:firstleg:container:list'))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
|
||||
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
||||
const canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
@@ -108,6 +125,13 @@ const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '
|
||||
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
|
||||
const truckingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.some((type) => ['CARRIER', 'SUPPLIER'].includes(type))))
|
||||
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
|
||||
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
||||
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
|
||||
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
|
||||
capacityCbm: [{ required: true, message: '请输入容量', trigger: 'change' }],
|
||||
maxWeightKg: [{ required: true, message: '请输入限重', trigger: 'change' }],
|
||||
}
|
||||
|
||||
interface PortOption { value: string; code: string | null; name: string | null }
|
||||
function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' }
|
||||
@@ -144,6 +168,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'cabinetNo', label: '柜次', minWidth: 180, slotName: 'cabinet' },
|
||||
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
||||
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
|
||||
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
|
||||
@@ -276,6 +301,103 @@ async function loadAllOceanBookings() {
|
||||
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200 })))
|
||||
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
|
||||
}
|
||||
function openSimulation() {
|
||||
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 })
|
||||
simulationChannels.value = []
|
||||
simulationError.value = null
|
||||
simulationOpen.value = true
|
||||
}
|
||||
async function selectSimulationWarehouse(value: unknown) {
|
||||
simulationForm.warehouseId = String(value || '')
|
||||
simulationForm.channelCode = ''
|
||||
simulationChannels.value = []
|
||||
simulationError.value = null
|
||||
if (!simulationForm.warehouseId) return
|
||||
simulationChannelsLoading.value = true
|
||||
try { simulationChannels.value = await listWarehouseLogisticsChannels(simulationForm.warehouseId) }
|
||||
catch (error) { simulationError.value = getApiErrorDetails(error, '物流渠道加载失败') }
|
||||
finally { simulationChannelsLoading.value = false }
|
||||
}
|
||||
async function saveSimulation() {
|
||||
if (!(await simulationFormRef.value?.validate().catch(() => false))) return
|
||||
simulationSaving.value = true
|
||||
simulationError.value = null
|
||||
try {
|
||||
await createContainerSimulation({ ...simulationForm })
|
||||
simulationOpen.value = false
|
||||
ElMessage.success('模拟柜已创建,可先排货后关联真实订舱柜')
|
||||
await loadRows()
|
||||
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
|
||||
finally { simulationSaving.value = false }
|
||||
}
|
||||
async function loadBookingStatus(status: 'CONFIRMED' | 'RELEASED') {
|
||||
const first = await listOceanBookings({ page: 1, size: 200, status })
|
||||
if (first.pages <= 1) return first.records
|
||||
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status })))
|
||||
return [first, ...rest].flatMap((result) => result.records)
|
||||
}
|
||||
async function openLink(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
linkTarget.value = row
|
||||
linkCandidateKey.value = ''
|
||||
linkCandidates.value = []
|
||||
linkError.value = null
|
||||
linkOpen.value = true
|
||||
linkLoading.value = true
|
||||
try {
|
||||
const bookings = (await Promise.all([loadBookingStatus('CONFIRMED'), loadBookingStatus('RELEASED')])).flat()
|
||||
.filter((booking) => booking.warehouseId === row.warehouseId && booking.channelCode === row.channelCode)
|
||||
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
|
||||
linkCandidates.value = details.flatMap((booking) => {
|
||||
const allocated = new Set(booking.containerAllocations.filter((item) => item.containerBookingId).map((item) => item.requirementId))
|
||||
return booking.containerRequirements.filter((requirement) => requirement.id
|
||||
&& !allocated.has(requirement.id)
|
||||
&& requirement.containerType === row.containerType
|
||||
&& Number(requirement.capacityCbm) === Number(row.capacityCbm)
|
||||
&& Number(requirement.maxWeightKg) === Number(row.maxWeightKg)).map((requirement) => ({
|
||||
key: `${booking.id}:${requirement.id}`,
|
||||
oceanBookingId: booking.id,
|
||||
requirementId: requirement.id!,
|
||||
detail: booking,
|
||||
label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} · ${booking.customerName || '未关联客户'}`,
|
||||
}))
|
||||
})
|
||||
} catch (error) { linkError.value = getApiErrorDetails(error, '真实订舱柜候选加载失败') }
|
||||
finally { linkLoading.value = false }
|
||||
}
|
||||
async function saveLink() {
|
||||
const target = linkTarget.value
|
||||
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
|
||||
if (!target || !candidate) { linkError.value = { message: '请选择要关联的真实订舱柜' }; return }
|
||||
linkSaving.value = true
|
||||
linkError.value = null
|
||||
try {
|
||||
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })
|
||||
linkOpen.value = false
|
||||
ElMessage.success('已关联真实订舱柜,内部柜次与装柜明细保持不变')
|
||||
await loadRows()
|
||||
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
|
||||
finally { linkSaving.value = false }
|
||||
}
|
||||
function openUnlink(source: object) {
|
||||
linkTarget.value = source as ContainerBookingResource
|
||||
unlinkReason.value = ''
|
||||
linkError.value = null
|
||||
unlinkOpen.value = true
|
||||
}
|
||||
async function saveUnlink() {
|
||||
const target = linkTarget.value
|
||||
if (!target || !unlinkReason.value.trim()) { linkError.value = { message: '请输入解除关联原因' }; return }
|
||||
linkSaving.value = true
|
||||
linkError.value = null
|
||||
try {
|
||||
await unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())
|
||||
unlinkOpen.value = false
|
||||
ElMessage.success('真实订舱关联已解除,内部柜次保持不变')
|
||||
await loadRows()
|
||||
} catch (error) { linkError.value = getApiErrorDetails(error, '解除真实订舱关联失败') }
|
||||
finally { linkSaving.value = false }
|
||||
}
|
||||
async function loadRows() {
|
||||
const sequence = ++listSequence
|
||||
loading.value = true; loadError.value = null
|
||||
@@ -306,6 +428,8 @@ async function openDetail(source: object) {
|
||||
}
|
||||
function handleMoreAction(command: string, source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
if (command === 'link') { void openLink(row); return }
|
||||
if (command === 'unlink') { openUnlink(row); return }
|
||||
if (command === 'overview') { void openDetail(row); return }
|
||||
detailTarget.value = row
|
||||
void openDetail(row).then(() => {
|
||||
@@ -458,8 +582,10 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
||||
</template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联真实订舱柜</el-dropdown-item><el-dropdown-item v-if="canEdit && row.oceanBookingId && row.planStatus === 'WAIT_LOADING'" command="unlink"><Unlink :size="15" />解除订舱关联</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>内部柜次</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo || '真实柜号待录入' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
|
||||
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
|
||||
@@ -476,7 +602,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
|
||||
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
|
||||
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="内部柜次">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="真实集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
@@ -485,6 +611,31 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
||||
|
||||
<AppDialog v-model="exceptionDialogOpen" :title="exceptionDialogTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="exceptionError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
|
||||
<el-alert title="模拟柜用于先排货,服务端会生成稳定的内部柜次;此处不填写真实柜号和船期。" type="info" :closable="false" />
|
||||
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelCode"><el-select v-model="simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select></el-form-item>
|
||||
<el-form-item label="柜型" prop="containerType"><el-select v-model="simulationForm.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="容量(m³)" prop="capacityCbm"><el-input-number v-model="simulationForm.capacityCbm" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="限重(kg)" prop="maxWeightKg"><el-input-number v-model="simulationForm.maxWeightKg" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="linkOpen" title="关联真实订舱柜" width="min(620px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink">
|
||||
<el-alert title="关联只补充真实订舱关系,不会重建内部柜次、装柜计划或已排货明细。" type="info" :closable="false" />
|
||||
<el-form label-position="top"><el-form-item label="内部柜次"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="可关联的真实订舱柜" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择同仓、同渠道、同规格的已确认订舱柜" no-data-text="暂无可关联的真实订舱柜"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></el-form>
|
||||
<p class="dialog-hint">已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联;最终状态门禁以后端为准。</p>
|
||||
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="unlinkOpen" title="解除真实订舱关联" width="min(520px, calc(100vw - 28px))" confirm-text="确认解除" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!unlinkReason.trim()" @confirm="saveUnlink">
|
||||
<el-alert title="只有尚未排货的空柜可以解除;已有排货明细、PDA 或单证事实时服务端会拒绝。内部柜次不会被删除。" type="warning" :closable="false" />
|
||||
<el-form label-position="top"><el-form-item label="解除原因" required><el-input v-model="unlinkReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
<LoadErrorState v-if="linkError" compact title="解除关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
|
||||
@@ -515,7 +666,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.document-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 16px; padding: 14px 16px; border: 1px solid var(--cc-border); background: #fff; }.document-panel > div { display: grid; gap: 4px; min-width: 0; }.document-panel span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
|
||||
@@ -149,15 +149,14 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps only customer, inbound code and destination warehouse filters', async () => {
|
||||
it('keeps only customer and inbound code filters', async () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>
|
||||
expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode', 'destinationWarehouse'])
|
||||
expect(fields.find((field) => field.key === 'destinationWarehouse')?.options).toEqual([
|
||||
{ label: 'LAX9', value: '9' },
|
||||
])
|
||||
expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode'])
|
||||
expect(source).not.toContain('目的仓')
|
||||
|
||||
expect(wrapper.text()).not.toContain('税率')
|
||||
expect(wrapper.text()).not.toContain('是否退税')
|
||||
|
||||
@@ -51,7 +51,7 @@ const assignOpen = ref(false)
|
||||
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
const quantityDrafts = reactive<Record<string, number>>({})
|
||||
const query = ref<QueryModel>({ customerId: '', inboundCode: '', destinationWarehouse: '' })
|
||||
const query = ref<QueryModel>({ customerId: '', inboundCode: '' })
|
||||
const appliedQuery = ref<QueryModel>({ ...query.value })
|
||||
let selectionRequest = 0
|
||||
|
||||
@@ -84,18 +84,9 @@ const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / vol
|
||||
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
|
||||
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
|
||||
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
||||
const destinationWarehouseOptions = computed(() => {
|
||||
const options = new Map<string, string>()
|
||||
for (const item of candidates.value) {
|
||||
const value = destinationWarehouseKey(item)
|
||||
if (value) options.set(value, item.destinationWarehouseName || item.destinationWarehouseCode || value)
|
||||
}
|
||||
return [...options].map(([value, label]) => ({ label, value }))
|
||||
})
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 },
|
||||
{ key: 'destinationWarehouse', type: 'select', label: '目的仓', placeholder: '全部目的仓', width: 200, options: destinationWarehouseOptions.value, props: { filterable: true } },
|
||||
])
|
||||
|
||||
const filteredContainers = computed(() => containers.value.filter((item) => (
|
||||
@@ -106,10 +97,8 @@ const filteredContainers = computed(() => containers.value.filter((item) => (
|
||||
const filteredCandidates = computed(() => {
|
||||
const customerId = String(appliedQuery.value.customerId || '')
|
||||
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
|
||||
const destinationWarehouse = String(appliedQuery.value.destinationWarehouse || '')
|
||||
return candidates.value.filter((item) => {
|
||||
if (customerId && item.accountId !== customerId) return false
|
||||
if (destinationWarehouse && destinationWarehouseKey(item) !== destinationWarehouse) return false
|
||||
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
|
||||
})
|
||||
})
|
||||
@@ -149,9 +138,6 @@ function adjustmentMax(source: object) {
|
||||
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
|
||||
return Math.max(1, item.packageCount + remaining)
|
||||
}
|
||||
function destinationWarehouseKey(candidate: SmartLoadingCandidate) {
|
||||
return candidate.destinationWarehouseId || candidate.destinationWarehouseCode || candidate.destinationWarehouseName || ''
|
||||
}
|
||||
function displayContainerNo(container: ContainerBookingResource | null | undefined) {
|
||||
if (!container) return '柜号待生成'
|
||||
const actual = String(container.containerNo || '').trim()
|
||||
@@ -460,11 +446,6 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
@click="selectContainer(item.id)"
|
||||
>
|
||||
<span class="container-option__top"><strong>{{ displayContainerNo(item) }}</strong></span>
|
||||
<span>箱型 {{ item.containerType }}</span>
|
||||
<span>{{ item.channelName || item.channelCode || '渠道未设置' }}</span>
|
||||
<span>{{ item.warehouseName || '仓库未设置' }}</span>
|
||||
<span v-if="item.id === selectedContainerId && plan">体积 {{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</span>
|
||||
<span v-else>容量 {{ formatNumber(Number(item.capacityCbm || 0)) }} m³ · {{ formatNumber(Number(item.maxWeightKg || 0), 0) }} kg</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface ContainerBookingResource {
|
||||
warehouseName: string | null
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
oceanBookingId?: string | null
|
||||
oceanBookingRequirementId?: string | null
|
||||
customerId?: string | null
|
||||
customerName?: string | null
|
||||
billOfLadingNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
@@ -39,6 +43,20 @@ export interface ContainerBookingResource {
|
||||
clearanceDocument?: BusinessDocument | null
|
||||
}
|
||||
|
||||
export interface ContainerSimulationCreateRequest {
|
||||
channelCode: string
|
||||
warehouseId: string
|
||||
containerType: string
|
||||
capacityCbm: number
|
||||
maxWeightKg: number
|
||||
}
|
||||
|
||||
export interface ContainerOceanBookingLinkRequest {
|
||||
oceanBookingId: string
|
||||
requirementId: string
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export interface BusinessDocument {
|
||||
id: string | null
|
||||
documentType: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { oceanBookingRoutes } from './routes'
|
||||
export { listOceanBookings } from './api/platformOceanBookings'
|
||||
export { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanLogisticsChannel } from './api/platformOceanBookings'
|
||||
export * from './status'
|
||||
export * from './types'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }); it('shows the two frequent row actions directly without a more menu', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('renders backend booking statuses through the domain translation contract', () => { expect(source).toContain('options: oceanBookingStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
|
||||
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('does not expose internal draft creation from the formal booking list', () => { expect(source).not.toContain('<template #toolbar-actions>'); expect(source).not.toContain('新增订舱'); expect(source).not.toContain("'/ocean-bookings/new'"); expect(source).not.toContain('PageToolbar') }); it('shows only the formal booking detail action', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).not.toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('limits status filters to the frozen formal booking states', () => { expect(source).toContain("['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']"); expect(source).toContain('options: formalStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
|
||||
|
||||
describe('OceanBookingsView prototype field matrix', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
|
||||
@@ -28,7 +28,6 @@ describe('OceanBookingsView visual action contract', () => {
|
||||
|
||||
it('uses bordered business buttons and stacks them on narrow screens', () => {
|
||||
expect(source).toContain('size="small" type="primary" plain')
|
||||
expect(source).toContain('size="small" @click="open(row.id)"')
|
||||
expect(source).not.toContain('link type="primary" @click="open(row.id)')
|
||||
expect(source).toContain('@media(max-width:420px)')
|
||||
expect(source).toContain('.table-actions')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
@@ -10,14 +10,12 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
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'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<OceanBookingResource[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
@@ -28,13 +26,14 @@ const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const query = reactive<QueryModel>({ customerId: '', keyword: '', status: '' })
|
||||
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
|
||||
const formalStatusOptions = oceanBookingStatusOptions.filter((item) => ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'].includes(item.value))
|
||||
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: 220 },
|
||||
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions },
|
||||
{ key: 'status', type: 'select', label: '状态', options: formalStatusOptions },
|
||||
])
|
||||
const columns = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 100, slotName: 'actions', hideable: false, movable: false },
|
||||
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ prop: 'carrierName', label: '船公司', minWidth: 130 },
|
||||
@@ -50,7 +49,7 @@ const columns = [
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
async function loadCustomers() { customersLoading.value = true; try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records.filter((customer) => customer.status === 'ACTIVE') } catch { customers.value = [] } finally { customersLoading.value = false } }
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, customerId: String(query.customerId || '') || undefined, keyword: String(query.keyword || '') || undefined, status: (query.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(id ? `/ocean-bookings/${id}` : '/ocean-bookings/new') }
|
||||
function open(id: string) { void router.push(`/ocean-bookings/${id}`) }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
watch([page, pageSize], () => void load()); onMounted(() => { void load(); void loadCustomers() })
|
||||
</script>
|
||||
@@ -61,8 +60,7 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
|
||||
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<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="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" size="small" @click="open(row.id)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Eye :size="15" />查看</el-button></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>
|
||||
|
||||
@@ -2,7 +2,7 @@ export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED
|
||||
|
||||
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
|
||||
export interface OceanBookingContainer { id?: string; containerType: string; quantity: number | null; containerKind?: string | null; capacityCbm: number | null; maxWeightKg: number | null }
|
||||
export interface OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null }
|
||||
export interface OceanBookingContainerAllocation { id?: string; oceanBookingId?: string; requirementId?: string; containerBookingId?: string | null; allocatedQuantity?: number | null }
|
||||
export interface OceanBookingDraftData {
|
||||
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
|
||||
channelCode: string; warehouseId: string; warehouseName: string
|
||||
|
||||
Reference in New Issue
Block a user