feat: connect plan-level preload confirmation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user