This commit is contained in:
2026-08-17 19:57:32 +08:00
parent ee4cd35927
commit 8f53c81196
15 changed files with 64 additions and 10 deletions
@@ -69,6 +69,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
...raw, ...raw,
id: id(raw.id), id: id(raw.id),
planId: optionalId(raw.planId), planId: optionalId(raw.planId),
planNo: raw.planNo || null,
mergedIntoContainerBookingId: optionalId(raw.mergedIntoContainerBookingId), mergedIntoContainerBookingId: optionalId(raw.mergedIntoContainerBookingId),
warehouseId: id(raw.warehouseId), warehouseId: id(raw.warehouseId),
oceanBookingId: optionalId(raw.oceanBookingId), oceanBookingId: optionalId(raw.oceanBookingId),
@@ -78,6 +79,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
version: numberValue(raw.version), version: numberValue(raw.version),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm), capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg), maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
exceptionActive: Boolean(raw.exceptionActive),
clearanceDocument: document(raw.clearanceDocument), clearanceDocument: document(raw.clearanceDocument),
} }
} }
@@ -21,7 +21,7 @@ import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecifi
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, unlinkContainerOceanBooking, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' 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' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & { id: string; version: number } type ContainerForm = ContainerSaveRequest & { id: string; version: number }
@@ -169,10 +169,16 @@ const queryFields = computed<QueryField[]>(() => [
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' }, { 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: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' }, { key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
{ key: 'customsDeclarationDocumentStatus', label: '报关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
{ key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 }, { key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 },
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 }, { key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 },
@@ -190,6 +196,7 @@ const columns: DataTableColumn[] = [
function optional(value: string | null) { return value?.trim() || null } function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] } function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function containerViewLabel(row: ContainerBookingResource) { function containerViewLabel(row: ContainerBookingResource) {
const reference = [row.cabinetNo, row.containerNo, row.bookingNo] const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
@@ -615,7 +622,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<AppDialog v-model="detailOpen" class="container-detail-dialog" title="货柜详情" width="min(820px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false" cancel-text="关闭"> <AppDialog v-model="detailOpen" class="container-detail-dialog" title="货柜详情" width="min(820px, calc(100vw - 28px))" :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> <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.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> <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.planNo || '—' }}</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.exceptionActive ? '异常' : '正常' }}</el-descriptions-item><el-descriptions-item label="目的港清关资料">{{ documentStatusText(detail.destinationClearanceDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="报关资料">{{ documentStatusText(detail.customsDeclarationDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="DO资料">{{ documentStatusText(detail.deliveryOrderDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="PU资料">{{ documentStatusText(detail.pickupOrderDocumentStatus) }}</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>
</AppDialog> </AppDialog>
<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> <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>
+7
View File
@@ -3,6 +3,7 @@ export type ContainerPlanStatus = 'WAIT_LOADING' | 'EDITING' | 'LOADING' | 'COMP
export interface ContainerBookingResource { export interface ContainerBookingResource {
id: string id: string
planId: string | null planId: string | null
planNo?: string | null
bookingNo: string bookingNo: string
containerNo: string | null containerNo: string | null
cabinetNo: string cabinetNo: string
@@ -40,6 +41,11 @@ export interface ContainerBookingResource {
estimatedWarehouseArrivalAt: string | null estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null actualWarehouseArrivalAt: string | null
planStatus: ContainerPlanStatus planStatus: ContainerPlanStatus
exceptionActive?: boolean
destinationClearanceDocumentStatus?: ContainerDocumentStatus
customsDeclarationDocumentStatus?: ContainerDocumentStatus
deliveryOrderDocumentStatus?: ContainerDocumentStatus
pickupOrderDocumentStatus?: ContainerDocumentStatus
version: number version: number
createdAt?: string | null createdAt?: string | null
updatedAt?: string | null updatedAt?: string | null
@@ -75,6 +81,7 @@ export interface BusinessDocument {
} }
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'CUSTOMS_DECLARATION' | 'DELIVERY_ORDER' | 'PICKUP_ORDER' export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'CUSTOMS_DECLARATION' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
export type ContainerDocumentStatus = 'MISSING' | 'UPLOADED' | 'COMPLETED'
export interface ContainerOverviewDocument { export interface ContainerOverviewDocument {
id: string id: string
@@ -48,6 +48,17 @@ describe('CarrierRatesView price-book source contract', () => {
expect(source).not.toContain('草稿') expect(source).not.toContain('草稿')
}) })
it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => {
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) {
expect(source).toContain(`label="${label}"`)
}
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
expect(source).toContain(`selectedChannel?.${field}`)
}
expect(source).not.toContain('AMS+换单费')
expect(source).not.toContain('建议报价')
})
it('maps only active platform public domestic and overseas warehouses before confirmation', () => { it('maps only active platform public domestic and overseas warehouses before confirmation', () => {
expect(source).toContain('listAllPlatformWarehouses()') expect(source).toContain('listAllPlatformWarehouses()')
expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'") expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'")
@@ -82,9 +82,12 @@ export interface LoadingItem {
itemId: string itemId: string
accountId: string accountId: string
customerName: string | null customerName: string | null
orderId: string
orderNo: string
inboundId: string inboundId: string
inboundDetailId: string inboundDetailId: string
inboundCode: string | null inboundCode: string | null
containerNo: string | null
skuCode: string | null skuCode: string | null
destinationWarehouse: string | null destinationWarehouse: string | null
chineseName: string | null chineseName: string | null
@@ -237,6 +240,7 @@ function normalizeItem(raw: LoadingItem): LoadingItem {
...raw, ...raw,
itemId: requiredId(raw.itemId), itemId: requiredId(raw.itemId),
accountId: requiredId(raw.accountId), accountId: requiredId(raw.accountId),
orderId: requiredId(raw.orderId),
inboundId: requiredId(raw.inboundId), inboundId: requiredId(raw.inboundId),
inboundDetailId: requiredId(raw.inboundDetailId), inboundDetailId: requiredId(raw.inboundDetailId),
productQuantity: String(raw.productQuantity ?? '0'), productQuantity: String(raw.productQuantity ?? '0'),
@@ -27,6 +27,8 @@ const emit = defineEmits<{ retry: []; edit: []; confirmPreload: []; attachCustom
const itemColumns: DataTableColumn[] = [ const itemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false }, { key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 150 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 }, { key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 130 }, { key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 130 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
@@ -17,6 +17,7 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
customerId: raw.customerId == null ? null : String(raw.customerId), customerId: raw.customerId == null ? null : String(raw.customerId),
shippingCompanyId: raw.shippingCompanyId == null ? null : String(raw.shippingCompanyId), shippingCompanyId: raw.shippingCompanyId == null ? null : String(raw.shippingCompanyId),
bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId), bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId),
containerCount: Number(raw.containerCount ?? 0),
pol: raw.pol ?? raw.departurePortName ?? null, pol: raw.pol ?? raw.departurePortName ?? null,
pod: raw.pod ?? raw.destinationPortName ?? null, pod: raw.pod ?? raw.destinationPortName ?? null,
carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null, carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null,
@@ -40,6 +40,9 @@ const columns = [
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 }, { prop: 'bookingNo', label: '订舱号', minWidth: 150 },
{ prop: 'customerName', label: '客户', minWidth: 160 }, { prop: 'customerName', label: '客户', minWidth: 160 },
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155 }, { prop: 'billOfLadingNo', label: '提单号', minWidth: 155 },
{ prop: 'shipperName', label: '提单发货人', minWidth: 160 },
{ prop: 'consigneeName', label: '提单收货人', minWidth: 160 },
{ prop: 'containerCount', label: '柜量', width: 90, align: 'right' },
{ prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'carrierName', label: '船公司', minWidth: 130 },
{ prop: 'bookingAgent', label: '订舱代理', minWidth: 140 }, { prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
{ prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' }, { prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' },
+1
View File
@@ -21,6 +21,7 @@ export interface OceanBookingDraftData {
export interface OceanBookingResource { export interface OceanBookingResource {
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
customerId: string | null; customerName: string | null; billOfLadingNo: string | null customerId: string | null; customerName: string | null; billOfLadingNo: string | null
shipperName: string | null; consigneeName: string | null; containerCount: number
channelCode: string | null; channelName: string | null; warehouseId: string | null; warehouseName: string | null channelCode: string | null; channelName: string | null; warehouseId: string | null; warehouseName: string | null
shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null
vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName: string | null vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName: string | null
@@ -48,7 +48,8 @@ const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false }, { key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false },
{ prop: 'inboundCode', label: '入仓码', minWidth: 145 }, { prop: 'inboundCode', label: '入仓码', minWidth: 145 },
{ prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'customerName', label: '客户', minWidth: 140 },
{ prop: 'containerNo', label: '柜号', minWidth: 130 }, { prop: 'cabinetNo', label: '柜次号', minWidth: 145 },
{ prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' }, { prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' },
{ prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck' }, { prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck' },
{ prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick' }, { prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick' },
@@ -209,7 +210,7 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
<AppDialog v-model="detailOpen" class="outbound-detail-dialog" title="国内出仓详情" width="min(900px, calc(100vw - 28px))" :show-confirm="false"> <AppDialog v-model="detailOpen" class="outbound-detail-dialog" title="国内出仓详情" width="min(900px, calc(100vw - 28px))" :show-confirm="false">
<LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState> <LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
<div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div> <div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div>
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="入仓码">{{ detail.aggregate.inboundCode || '—' }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table class="gate-table" :data="detail.aggregate.gate?.items || []" border stripe><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, entry.toStatus) }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template> <template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="入仓码">{{ detail.aggregate.inboundCode || '—' }}</el-descriptions-item><el-descriptions-item label="柜次号">{{ detail.aggregate.cabinetNo || '—' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table class="gate-table" :data="detail.aggregate.gate?.items || []" border stripe><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, entry.toStatus) }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
</AppDialog> </AppDialog>
</AdminPage> </AdminPage>
</template> </template>
+1 -1
View File
@@ -1,5 +1,5 @@
export interface OutboundGateItem { code: string; name: string; passed: boolean; actual?: string | null; requirement?: string | null } export interface OutboundGateItem { code: string; name: string; passed: boolean; actual?: string | null; requirement?: string | null }
export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]; checkedAt: string } export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]; checkedAt: string }
export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null } export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; cabinetNo?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> } export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string } export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
+2 -1
View File
@@ -32,11 +32,12 @@ function normalizePage(payload: PagePayload): TruckAppointmentPage {
} }
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource { function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'customerId', 'carrierId'] const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'customerId', 'shippingCompanyId', 'bookingAgentId', 'carrierId']
const normalized = { ...resource } as Record<string, unknown> const normalized = { ...resource } as Record<string, unknown>
for (const key of idKeys) { for (const key of idKeys) {
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key]) if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
} }
normalized.attachmentCount = Number(normalized.attachmentCount ?? 0)
return normalized as unknown as TruckAppointmentResource return normalized as unknown as TruckAppointmentResource
} }
@@ -30,8 +30,13 @@ const fields = computed<QueryField[]>(() => [
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 }, { key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || (row.containerBookingId ? '已关联柜次' : '—') }, { key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.cabinetNo || '—' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || '—' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.customerName || '客户信息缺失' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.customerName || '客户信息缺失' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.warehouseName || '—' },
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.shippingCompanyName || '—' },
{ key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' },
{ key: 'company', label: '运输公司', minWidth: 160, slotName: 'company' }, { key: 'company', label: '运输公司', minWidth: 160, slotName: 'company' },
{ key: 'driver', label: '司机', minWidth: 130, slotName: 'driver' }, { key: 'driver', label: '司机', minWidth: 130, slotName: 'driver' },
{ key: 'vehicleNo', prop: 'vehicleNo', label: '车头号', minWidth: 120, formatter: (row: TruckAppointmentResource) => row.vehicleNo || '—' }, { key: 'vehicleNo', prop: 'vehicleNo', label: '车头号', minWidth: 120, formatter: (row: TruckAppointmentResource) => row.vehicleNo || '—' },
@@ -40,7 +45,7 @@ const columns: DataTableColumn[] = [
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.receivableAmount, row.currency) }, { key: 'receivableAmount', label: '应收拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.receivableAmount, row.currency) },
{ key: 'plannedLoadingAt', label: '预计装箱日期', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) }, { key: 'plannedLoadingAt', label: '预计装箱日期', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) },
{ key: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'status', label: '状态', width: 130, slotName: 'status' },
{ key: 'attachments', label: '附件', width: 90, formatter: (row: TruckAppointmentResource) => String(row.attachments?.length || 0) }, { key: 'attachmentCount', label: '附件', width: 90, align: 'right', formatter: (row: TruckAppointmentResource) => String(Number(row.attachmentCount ?? 0)) },
{ key: 'updatedAt', label: '更新时间', minWidth: 160, formatter: (row: TruckAppointmentResource) => formatDateTime(row.updatedAt) }, { key: 'updatedAt', label: '更新时间', minWidth: 160, formatter: (row: TruckAppointmentResource) => formatDateTime(row.updatedAt) },
] ]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
+8
View File
@@ -86,11 +86,18 @@ export interface TruckAppointmentResource {
revision?: number | null revision?: number | null
outboundOrderId?: string | null outboundOrderId?: string | null
oceanBookingId?: string | null oceanBookingId?: string | null
oceanBookingNo?: string | null
containerBookingId?: string | null containerBookingId?: string | null
cabinetNo?: string | null
loadingPlanId?: string | null loadingPlanId?: string | null
warehouseId?: string | null warehouseId?: string | null
warehouseName?: string | null
customerId?: string | null customerId?: string | null
customerName?: string | null customerName?: string | null
shippingCompanyId?: string | null
shippingCompanyName?: string | null
bookingAgentId?: string | null
bookingAgentName?: string | null
carrierId?: string | null carrierId?: string | null
carrierName?: string | null carrierName?: string | null
fleetContact?: string | null fleetContact?: string | null
@@ -123,6 +130,7 @@ export interface TruckAppointmentResource {
relatedContainerType?: string | null relatedContainerType?: string | null
entrustingPartyName?: string | null entrustingPartyName?: string | null
attachments?: TruckAttachmentReference[] | null attachments?: TruckAttachmentReference[] | null
attachmentCount?: number | string | null
statusHistory?: TruckAppointmentStatusHistory[] | null statusHistory?: TruckAppointmentStatusHistory[] | null
createdAt?: string | null createdAt?: string | null
updatedAt?: string | null updatedAt?: string | null
@@ -182,6 +182,7 @@ describe('WarehousesView', () => {
expect(warehousesSource).toMatch(/size="small"\s+plain\s+:type=/) expect(warehousesSource).toMatch(/size="small"\s+plain\s+:type=/)
expect(warehousesSource).toContain('<Pencil :size="15" />') expect(warehousesSource).toContain('<Pencil :size="15" />')
expect(warehousesSource).toContain('<Power :size="15" />') expect(warehousesSource).toContain('<Power :size="15" />')
for (const label of ['仓库名称', '仓库编码', '州/省', '城市', '邮编']) expect(warehousesSource).toContain(`label: '${label}'`)
expect(warehousesSource).toContain('@media (max-width: 720px)') expect(warehousesSource).toContain('@media (max-width: 720px)')
}) })
it('uses the prototype-sized centered warehouse editor with a fixed footer', () => { it('uses the prototype-sized centered warehouse editor with a fixed footer', () => {