feat: connect plan-level preload confirmation

This commit is contained in:
2026-08-15 17:04:00 +08:00
parent fc80d09faa
commit 835d1d0216
26 changed files with 398 additions and 267 deletions
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
getConfirmedLoadingList,
confirmPreloadPlan,
uploadLoadingDeclaration,
completeLoadingDeclaration,
getPreloadPlan,
@@ -15,7 +16,7 @@ const preloadSummary = {
containerBookingId: 1900000000000000004n as unknown as string,
preloadNo: 'PL-001',
containerNo: 'MSCU1234567',
internalContainerNo: 'YG-001',
cabinetNo: 'YG-001',
customers: [{ accountId: 1900000000000000002n as unknown as string, customerName: '测试客户' }],
warehouseId: 1900000000000000003n as unknown as string,
warehouseName: '义乌仓',
@@ -29,6 +30,20 @@ const preloadSummary = {
totalGrossWeightKg: 1200,
createdAt: '2026-08-10T10:00:00',
version: 3,
readiness: {
preloadPushed: true,
containerNoReady: true,
preloadLinkCount: 1,
confirmedPreloadCount: 0,
oceanBookingId: 1900000000000000005n as unknown as string,
oceanBookingStatus: 'RELEASED',
pdaTaskId: null,
pdaTaskStatus: null,
confirmationStatus: 'PENDING',
canConfirm: true,
readyForPda: false,
blockingReasons: [],
},
}
const detail = {
@@ -54,6 +69,7 @@ describe('platform loading lists API', () => {
expect(result.total).toBe(1)
expect(result.records[0]?.planId).toBe('1900000000000000001')
expect(result.records[0]?.containerBookingId).toBe('1900000000000000004')
expect(result.records[0]?.readiness.oceanBookingId).toBe('1900000000000000005')
expect(result.records[0]?.customers[0]?.accountId).toBe('1900000000000000002')
})
@@ -92,6 +108,42 @@ describe('platform loading lists API', () => {
expect(loaded.summary.status).toBe('COMPLETED')
})
it('confirms the whole preload plan with expectedVersion and the caller operation id', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({
code: '200', message: 'ok', data: {
planId: 1900000000000000001n,
containerBookingId: 1900000000000000004n,
confirmationStatus: 'CONFIRMED',
version: 4,
readiness: {
...preloadSummary.readiness,
confirmedPreloadCount: 1,
confirmationStatus: 'CONFIRMED',
canConfirm: false,
readyForPda: true,
pdaTaskId: 1900000000000000006n,
pdaTaskStatus: 'PENDING',
blockingReasons: ['PRELOAD_ALREADY_CONFIRMED'],
},
},
})
const result = await confirmPreloadPlan('1900000000000000001', 3, 'confirm-operation-1')
expect(post).toHaveBeenCalledWith(
'/platform/v1/first-leg/loading-plans/1900000000000000001/confirm',
{ expectedVersion: 3 },
{ headers: { 'Idempotency-Key': 'confirm-operation-1' } },
)
expect(result).toMatchObject({
planId: '1900000000000000001',
containerBookingId: '1900000000000000004',
confirmationStatus: 'CONFIRMED',
version: 4,
readiness: { pdaTaskId: '1900000000000000006', readyForPda: true },
})
})
it('uses stable plan and warehouse ids for customs document actions', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { id: 6, documentType: 'CUSTOMS_DECLARATION', fileId: 9, fileName: 'declaration.pdf', version: 2 } })
const file = new File(['declaration'], 'declaration.pdf', { type: 'application/pdf' })
@@ -7,12 +7,38 @@ export interface LoadingCustomer {
customerName: string | null
}
export type PreloadConfirmationStatus = 'PENDING' | 'PARTIALLY_CONFIRMED' | 'CONFIRMED'
export type PreloadBlockingReason =
| 'PRELOAD_NOT_PUSHED'
| 'LOADING_PLAN_NOT_EDITING'
| 'CONTAINER_NO_MISSING'
| 'PRELOAD_LINK_MISSING'
| 'OCEAN_BOOKING_NOT_LINKED'
| 'OCEAN_BOOKING_NOT_RELEASED'
| 'PDA_TASK_ALREADY_CREATED'
| 'PRELOAD_ALREADY_CONFIRMED'
export interface PreloadReadiness {
preloadPushed: boolean
containerNoReady: boolean
preloadLinkCount: number
confirmedPreloadCount: number
oceanBookingId: string | null
oceanBookingStatus: 'CONFIRMED' | 'CHANGE_PENDING' | 'RELEASED' | 'CANCELLED' | null
pdaTaskId: string | null
pdaTaskStatus: string | null
confirmationStatus: PreloadConfirmationStatus
canConfirm: boolean
readyForPda: boolean
blockingReasons: PreloadBlockingReason[]
}
export interface PreloadSummary {
planId: string
containerBookingId: string
preloadNo: string
containerNo: string | null
internalContainerNo: string | null
cabinetNo: string | null
customers: LoadingCustomer[]
warehouseId: string | null
warehouseName: string | null
@@ -26,6 +52,7 @@ export interface PreloadSummary {
totalGrossWeightKg: number
createdAt: string
version: number
readiness: PreloadReadiness
}
export interface ConfirmedSummary {
@@ -35,7 +62,7 @@ export interface ConfirmedSummary {
warehouseId: string | null
warehouseName: string | null
containerNo: string | null
internalContainerNo: string | null
cabinetNo: string | null
status: string
totalPackageCount: string
totalGrossWeightKg: number
@@ -142,6 +169,14 @@ export interface TransportUpdateRequest {
operationReason: string | null
}
export interface PreloadConfirmationResponse {
planId: string
containerBookingId: string
confirmationStatus: 'CONFIRMED'
version: number
readiness: PreloadReadiness
}
type RawId = string | number | null | undefined
type RawPage<T extends LoadingListSummary> = Omit<LoadingListPage<T>, 'page' | 'size' | 'total' | 'pages'> & {
page: string | number
@@ -160,6 +195,15 @@ function optionalId(value: RawId) {
return value === null || value === undefined ? null : String(value)
}
function normalizePreloadReadiness(raw: PreloadReadiness): PreloadReadiness {
return {
...raw,
oceanBookingId: optionalId(raw.oceanBookingId),
pdaTaskId: optionalId(raw.pdaTaskId),
blockingReasons: raw.blockingReasons || [],
}
}
function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary {
return {
...raw,
@@ -168,6 +212,7 @@ function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary {
warehouseId: optionalId(raw.warehouseId),
skuCount: String(raw.skuCount ?? '0'),
totalPackageCount: String(raw.totalPackageCount ?? '0'),
readiness: normalizePreloadReadiness(raw.readiness),
customers: (raw.customers || []).map((customer) => ({
...customer,
accountId: requiredId(customer.accountId),
@@ -206,6 +251,7 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
operatorId: requiredId(raw.operatorId),
}
}
function normalizeDocument(raw: BusinessDocument | null | undefined): BusinessDocument | null {
if (!raw) return null
return { ...raw, id: raw.id == null ? null : requiredId(raw.id), fileId: requiredId(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), completedBy: optionalId(raw.completedBy), version: Number(raw.version ?? 0) }
@@ -262,6 +308,21 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
}
}
export async function confirmPreloadPlan(planId: string, expectedVersion: number, operationId: string) {
const response = await http.post<ApiEnvelope<PreloadConfirmationResponse>, ApiEnvelope<PreloadConfirmationResponse>>(
`${basePath}/loading-plans/${encodeURIComponent(planId)}/confirm`,
{ expectedVersion },
{ headers: { 'Idempotency-Key': operationId } },
)
const result = unwrapApiResponse(response)
return {
...result,
planId: requiredId(result.planId),
containerBookingId: requiredId(result.containerBookingId),
readiness: normalizePreloadReadiness(result.readiness),
}
}
export async function getLoadingDeclaration(planId: string, warehouseId: string) {
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs`, { params: { warehouseId } })
return normalizeDocument(unwrapApiResponse(response))
@@ -17,11 +17,12 @@ const props = defineProps<{
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
confirmSaving?: boolean
canManageCustoms?: boolean
customsSaving?: boolean
}>()
const emit = defineEmits<{ retry: []; edit: []; attachCustoms: []; completeCustoms: [] }>()
const emit = defineEmits<{ retry: []; edit: []; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const itemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
@@ -45,6 +46,28 @@ const itemColumns: DataTableColumn[] = [
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
const blockingReasonLabels: Record<string, string> = {
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
CONTAINER_NO_MISSING: '尚未录入集装箱号',
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
OCEAN_BOOKING_NOT_LINKED: '尚未关联真实订舱',
OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱',
PDA_TASK_ALREADY_CREATED: 'PDA 任务已创建,无需重复确认',
PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认',
}
const confirmationStatusLabels: Record<string, string> = {
PENDING: '待确认', PARTIALLY_CONFIRMED: '部分票据已确认', CONFIRMED: '已确认',
}
const oceanBookingStatusLabels: Record<string, string> = {
CONFIRMED: '已确认', CHANGE_PENDING: '改船处理中', RELEASED: '已放舱', CANCELLED: '已取消',
}
const pdaTaskStatusLabels: Record<string, string> = {
PENDING: '待领取', CLAIMED: '已领取', LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
}
const blockingReasonText = computed(() => (preloadSummary.value?.readiness.blockingReasons || [])
.map((reason) => blockingReasonLabels[reason] || '存在未满足的确认条件')
.join(';'))
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
@@ -62,6 +85,7 @@ function actionLabel(action: string) {
return ({
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
CONFIRM_PRELOAD: '确认预装',
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
} as Record<string, string>)[action] || '状态更新'
}
@@ -86,13 +110,16 @@ function formatNumber(value?: number | null, digits = 3) {
<DetailPageLayout :ariaLabel="title">
<template #summary>
<section class="detail-summary" aria-label="清单基本信息">
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待录入' }}</strong></div>
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
<div><span>柜号</span><strong>{{ summary.internalContainerNo || '—' }}</strong></div>
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
<template v-if="preloadSummary">
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
<div><span>预装确认</span><StatusBadge :status="confirmationStatusLabels[preloadSummary.readiness.confirmationStatus] || '未知状态'" /></div>
<div><span>订舱状态</span><strong>{{ preloadSummary.readiness.oceanBookingStatus ? (oceanBookingStatusLabels[preloadSummary.readiness.oceanBookingStatus] || '未知状态') : '未关联' }}</strong></div>
<div><span>PDA 状态</span><strong>{{ preloadSummary.readiness.pdaTaskStatus ? (pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态') : (preloadSummary.readiness.readyForPda ? '可下发' : '未就绪') }}</strong></div>
<div class="detail-summary__wide"><span>客户</span><strong>{{ preloadSummary.customers.map((item) => item.customerName || '客户信息缺失').join('、') || '客户信息缺失' }}</strong></div>
</template>
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
@@ -100,8 +127,11 @@ function formatNumber(value?: number | null, digits = 3) {
</template>
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />编辑运输信息</el-button>
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
<el-button type="success" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
</div>
<el-alert v-if="preloadSummary?.readiness.confirmationStatus === 'CONFIRMED'" type="success" :closable="false" show-icon title="整份预装计划已确认" :description="preloadSummary.readiness.pdaTaskStatus ? `PDA 任务状态:${pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态'}` : 'PDA 前置条件已满足,等待服务端生成任务。'" />
<el-alert v-else-if="preloadSummary && !preloadSummary.readiness.canConfirm" type="warning" :closable="false" show-icon title="当前暂不能确认预装" :description="blockingReasonText || '请刷新详情获取最新确认条件。'" />
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
<section class="detail-section surface-panel">
@@ -27,22 +27,36 @@ describe('LoadingListsView', () => {
expect(source).not.toContain("key: 'warehouseId'")
expect(source).not.toContain('loadingWarehouses')
expect(source).not.toContain('warehouseId: warehouseId || undefined')
expect(source).toContain("selectedWarehouseId.value = String(row.warehouseId || '')")
expect(source).toContain('query: { warehouseId: String(row.warehouseId || \'\') }')
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
expect(source).not.toContain("'请先选择仓库后查询'")
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
expect(preloadDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
expect(confirmedDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
})
it('uses the shared query, table, dialog and error components without a detail drawer', () => {
it('uses the shared query and table components while keeping details on route pages', () => {
expect(source).toContain('QueryToolbar')
expect(source).toContain('DataTable')
expect(source).toContain('AppDialog')
expect(source).toContain('LoadErrorState')
expect(source).toContain('ApiErrorMeta')
expect(preloadDetailSource).toContain('AppDialog')
expect(confirmedDetailSource).toContain('AppDialog')
expect(detailSource).not.toContain('AppDrawer')
expect(detailSource).toContain('ContainerFeesPanel')
})
it('distinguishes the generated cabinet reference from the physical container number', () => {
expect(source).toContain("label: '柜次号'")
expect(source).toContain("label: '集装箱号'")
expect(source).toContain('装柜清单号/柜次号/集装箱号')
expect(detailSource).toContain('<span>柜次号</span>')
expect(detailSource).toContain('<span>集装箱号</span>')
expect(source).not.toContain("label: '柜号'")
expect(detailSource).not.toContain('<span>柜号</span>')
expect(source).toContain("key: 'cabinetNo', prop: 'cabinetNo'")
expect(source).not.toContain('internalContainerNo')
})
it('opens both preload and confirmed details as independent route pages', () => {
expect(source).toContain("name: 'preload-plan-detail'")
expect(source).toContain("name: 'loading-list-detail'")
@@ -55,20 +69,19 @@ describe('LoadingListsView', () => {
expect(routesSource).toContain("name: 'loading-list-detail'")
})
it('keeps the real transport edit contract in a 620px centered modal', () => {
expect(source).toContain('class="loading-transport-dialog"')
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
expect(source).not.toContain('class="loading-transport-dialog"')
expect(preloadDetailSource).toContain('width="min(620px, calc(100vw - 28px))"')
expect(source).not.toContain('备注信息(仅自己可见)')
expect(source).not.toContain('备注信息(客户可见)')
expect(source).not.toContain('备注信息(仓库可见)')
expect(preloadDetailSource).not.toContain('备注信息(仅自己可见)')
expect(preloadDetailSource).not.toContain('备注信息(客户可见)')
expect(preloadDetailSource).not.toContain('备注信息(仓库可见)')
})
it('only exposes transport editing for LOADING preload details', () => {
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
expect(detailSource).toContain('报关资料')
expect(detailSource).toContain('完成报关')
expect(source).toContain('expectedVersion: detail.value.summary.version')
expect(preloadDetailSource).toContain('expectedVersion: detail.value.summary.version')
})
it('renders business labels instead of raw loading-plan status codes', () => {
@@ -78,16 +91,17 @@ describe('LoadingListsView', () => {
expect(source).not.toContain('仅装柜中允许修改')
})
it('exposes a row edit action only for loading preload plans', () => {
expect(source).toContain("isPreload && canEdit && ['EDITING', 'LOADING'].includes(row.status)")
expect(source).toContain('openEditFromRow(row)')
expect(source).toContain('await loadDetail()')
it('keeps transport editing as a detail-page action', () => {
expect(source).not.toContain('openEditFromRow(row)')
expect(source).not.toContain('编辑运输信息')
expect(detailSource).toContain("{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}")
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')")
})
it('refreshes the detail version on A0409 while preserving transport input', () => {
expect(source).toContain("details.code === 'A0409'")
expect(source).toContain('await loadDetail()')
expect(source).toContain('当前输入已保留,请核对后重新保存')
expect(preloadDetailSource).toContain("details.code === 'A0409'")
expect(preloadDetailSource).toContain('await loadDetail()')
expect(preloadDetailSource).toContain('当前输入已保留,请核对后重新保存')
})
it('opens a validated plan and warehouse deep link without mixing list modes', () => {
@@ -99,6 +113,16 @@ describe('LoadingListsView', () => {
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
})
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
expect(detailSource).toContain("PRELOAD_NOT_PUSHED: '尚未推送预装清单'")
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
expect(detailSource).toContain("confirmationStatus === 'CONFIRMED'")
expect(detailSource).toContain("pdaTaskStatusLabels")
expect(preloadDetailSource).toContain('@confirm-preload="openConfirmPreload"')
})
it('passes translated status text directly to the badge', () => {
expect(source).toContain('<StatusBadge :status="loadingListStatusLabel(row.status)" />')
expect(source).not.toContain('<StatusBadge :status="row.status" />')
@@ -107,10 +131,8 @@ describe('LoadingListsView', () => {
it('uses bordered list actions with a stable 320px layout', () => {
expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" @click="openEditFromRow(row)"')
expect(source).toContain('编辑运输信息')
expect(source).not.toContain('<el-button v-if="canView" link')
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 230")
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 110")
expect(source).toContain('flex-wrap: wrap')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.row-actions { gap: 4px; }')
@@ -119,6 +141,7 @@ describe('LoadingListsView', () => {
})
it('does not expose unknown history action codes', () => {
expect(detailSource).toContain("CONFIRM_PRELOAD: '确认预装'")
expect(detailSource).toContain("[action] || '状态更新'")
expect(detailSource).not.toContain('[action] || action')
})
@@ -1,14 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Eye, Pencil } from '@lucide/vue'
import { Eye } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -16,12 +14,9 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { loadingListStatusLabel } from '../status'
import {
getPreloadPlan,
listConfirmedLoadingLists,
listPreloadPlans,
updatePreloadTransport,
type ConfirmedSummary,
type LoadingDetail,
type LoadingListMode,
type LoadingListQuery,
type LoadingListSummary,
@@ -41,16 +36,6 @@ const total = ref(0)
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<LoadingDetail | null>(null)
const selectedPlanId = ref('')
const selectedWarehouseId = ref('')
const editOpen = ref(false)
const editSaving = ref(false)
const editError = ref<ApiErrorDetails | null>(null)
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const handledDeepLink = ref('')
const isPreload = computed(() => props.mode === 'preload')
@@ -58,18 +43,17 @@ const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
const listPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:list' : 'platform:firstleg:loading-list:list')
const canList = computed(() => access.can(listPermission.value))
const canView = computed(() => canList.value)
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
const fields = 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: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/柜次号/集装箱号' : '装柜清单号/柜次号/集装箱号', width: 270 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
])
const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'actions', label: '操作', fixed: 'left', width: 230, slotName: 'actions', hideable: false, movable: false },
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 165 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
{ key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
{ key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' },
@@ -84,7 +68,7 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
@@ -97,12 +81,6 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
])
const editRules: FormRules = {
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
}
function formatDateTime(value?: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
@@ -165,27 +143,6 @@ function search() {
}
async function loadDetail() {
if (!canView.value) return
if (!selectedPlanId.value || !selectedWarehouseId.value) {
detail.value = null
detailError.value = {
message: '该清单缺少所属仓库信息,无法安全查询详情,请联系管理员核对数据。',
code: 'MISSING_WAREHOUSE_CONTEXT',
}
return
}
detailLoading.value = true
detailError.value = null
try {
detail.value = await getPreloadPlan(selectedPlanId.value, selectedWarehouseId.value)
} catch (cause) {
detailError.value = getApiErrorDetails(cause, '清单详情加载失败,请稍后重试')
} finally {
detailLoading.value = false
}
}
function openDetail(rowValue: object) {
const row = rowValue as LoadingListSummary
if (isPreload.value) {
@@ -195,59 +152,6 @@ function openDetail(rowValue: object) {
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
}
async function openEditFromRow(rowValue: object) {
const row = rowValue as LoadingListSummary
if (!isPreload.value || !canEdit.value || !['EDITING', 'LOADING'].includes(row.status)) return
selectedPlanId.value = String(row.planId)
selectedWarehouseId.value = String(row.warehouseId || '')
detail.value = null
detailError.value = null
await loadDetail()
const loadedDetail = detail.value as LoadingDetail | null
if (loadedDetail && ['EDITING', 'LOADING'].includes(loadedDetail.summary.status)) openEdit()
}
function openEdit() {
if (!detail.value || !('preloadNo' in detail.value.summary) || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
editForm.containerNo = detail.value.summary.containerNo || ''
editForm.voyageNo = detail.value.summary.voyageNo || ''
editForm.operationReason = ''
editError.value = null
editOpen.value = true
}
async function saveTransport() {
if (!detail.value || !('preloadNo' in detail.value.summary)) return
const valid = await editFormRef.value?.validate().catch(() => false)
if (!valid) return
editSaving.value = true
editError.value = null
try {
detail.value = await updatePreloadTransport(selectedPlanId.value, selectedWarehouseId.value, {
containerNo: editForm.containerNo.trim() || null,
voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim() || null,
expectedVersion: detail.value.summary.version,
})
editOpen.value = false
ElMessage.success('运输信息已更新')
await load()
} catch (cause) {
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await loadDetail()
editError.value = {
...details,
message: '运输信息已被其他人修改,系统已刷新最新版本;当前输入已保留,请核对后重新保存。',
}
} else {
editError.value = details
}
} finally {
editSaving.value = false
}
}
async function loadCustomers() {
customersLoading.value = true
try {
@@ -305,7 +209,6 @@ onMounted(() => {
<template #actions="{ row }">
<div class="row-actions">
<el-button v-if="canView" size="small" type="primary" plain @click="openDetail(row)"><Eye :size="15" />查看</el-button>
<el-button v-if="isPreload && canEdit && ['EDITING', 'LOADING'].includes(row.status)" size="small" @click="openEditFromRow(row)"><Pencil :size="15" />编辑运输信息</el-button>
</div>
</template>
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、') || '—' }}</template>
@@ -319,26 +222,6 @@ onMounted(() => {
</section>
</ListPageLayout>
<AppDialog
v-model="editOpen"
class="loading-transport-dialog"
title="编辑预装运输信息"
width="min(620px, calc(100vw - 28px))"
confirm-text="保存"
:confirm-loading="editSaving"
:confirm-disabled="!canEdit"
@confirm="saveTransport"
>
<el-alert type="info" :closable="false" show-icon title="仅拣货中允许修改;保存时按详情版本执行乐观锁校验。" />
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" show-word-limit placeholder="请输入集装箱号" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" show-word-limit placeholder="请输入航次" /></el-form-item>
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
</el-form>
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false">
<template #details><ApiErrorMeta :details="editError" /></template>
</LoadErrorState>
</AppDialog>
</div>
</AdminPage>
</template>
@@ -346,10 +229,7 @@ onMounted(() => {
<style scoped>
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.row-actions :deep(.el-button) { margin-left: 0; }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } .row-actions { gap: 4px; } }
@media (max-width: 720px) { .row-actions { gap: 4px; } }
</style>
@@ -25,4 +25,30 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
expect(source).not.toContain('saveMfNote')
})
it('is the formal container-number entry and does not imply that preload directly opens PDA', () => {
expect(source).toContain("{ required: true, message: '请输入集装箱号'")
expect(source).toContain("detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
expect(source).toContain('预装清单不会直接开放 PDA')
expect(source).toContain('订舱关联与放舱、预装确认后')
})
it('confirms the whole plan through readiness with stable idempotency and refreshes the detail', () => {
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)')
expect(source).toContain('summary?.readiness.canConfirm')
expect(source).toContain('confirmSaving.value')
expect(source).toContain('await loadDetail()')
expect(source).toContain('本操作将确认整份预装计划')
expect(source).toContain('全部入库预装票据')
expect(source).toContain('当前页面不会伪造 PDA 成功')
expect(source).not.toContain('/inbounds/')
})
it('keeps backend confirmation errors visible and supports narrow dialogs', () => {
expect(source).toContain("confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')")
expect(source).toContain(':message="confirmError.message"')
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
expect(source).toContain("label=\"柜次号\"")
expect(source).toContain("label=\"集装箱号\"")
})
})
@@ -10,7 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
import { confirmPreloadPlan, getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
@@ -21,13 +21,21 @@ const error = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editSaving = ref(false)
const editError = ref<ApiErrorDetails | null>(null)
const confirmOpen = ref(false)
const confirmSaving = ref(false)
const confirmError = ref<ApiErrorDetails | null>(null)
const confirmOperationId = ref('')
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')
const editRules: FormRules = {
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
containerNo: [
{ required: true, message: '请输入集装箱号', trigger: 'blur' },
{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' },
],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
}
@@ -74,7 +82,7 @@ async function saveTransport() {
editError.value = null
try {
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
containerNo: editForm.containerNo.trim() || null,
containerNo: editForm.containerNo.trim(),
voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim() || null,
expectedVersion: detail.value.summary.version,
@@ -92,6 +100,32 @@ async function saveTransport() {
}
}
function openConfirmPreload() {
const summary = detail.value?.summary
if (!canEdit.value || !summary?.readiness.canConfirm) return
confirmError.value = null
confirmOperationId.value = `admin-preload-confirm-${summary.planId}-${summary.version}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
confirmOpen.value = true
}
async function submitConfirmPreload() {
const summary = detail.value?.summary
if (!summary?.readiness.canConfirm || confirmSaving.value) return
confirmSaving.value = true
confirmError.value = null
try {
const result = await confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)
await loadDetail()
confirmOpen.value = false
confirmOperationId.value = ''
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已具备下发条件' : '预装已确认')
} catch (cause) {
confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')
} finally {
confirmSaving.value = false
}
}
onMounted(() => void loadDetail())
</script>
@@ -99,13 +133,21 @@ onMounted(() => void loadDetail())
<AdminPage>
<div class="preload-detail-page">
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
<el-alert
v-if="detail"
type="info"
:closable="false"
show-icon
:title="detail.summary.containerNo ? '集装箱号已录入,继续完成订舱放舱与预装确认' : '下一步:录入集装箱号'"
description="预装清单不会直接开放 PDA;需完成集装箱号、订舱关联与放舱、预装确认后,才由服务端生成 PDA 任务。"
/>
<LoadErrorState v-if="error && !detail" title="预装清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" @retry="loadDetail" @edit="openEdit" />
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @retry="loadDetail" @edit="openEdit" @confirm-preload="openConfirmPreload" />
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog" title="编辑预装运输信息" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
<AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入集装箱号" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
@@ -113,6 +155,18 @@ onMounted(() => void loadDetail())
</el-form>
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
<div class="confirm-content">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="系统会原子确认本计划包含的全部入库预装票据;成功后由服务端按真实条件创建唯一 PDA 任务,当前页面不会伪造 PDA 成功。" />
<el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item>
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待录入' }}</el-descriptions-item>
</el-descriptions>
<LoadErrorState v-if="confirmError" compact title="确认预装失败" :message="confirmError.message" :show-retry="false"><template #details><ApiErrorMeta :details="confirmError" /></template></LoadErrorState>
</div>
</AppDialog>
</AdminPage>
</template>
@@ -121,6 +175,7 @@ onMounted(() => void loadDetail())
.back-button { width: fit-content; }
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.confirm-content { display: grid; gap: 16px; }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
</style>
+1
View File
@@ -7,6 +7,7 @@ describe('loading list routes', () => {
['loading-plans', 'platform:firstleg:preload:list'],
['loading-plans/:planId', 'platform:firstleg:preload:list'],
['loading-lists', 'platform:firstleg:loading-list:list'],
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
])
})
})