This commit is contained in:
2026-09-22 13:51:58 +08:00
parent aefcd2565f
commit fed257a720
8 changed files with 174 additions and 45 deletions
@@ -68,9 +68,9 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain("['EDITING', 'LOADING'].includes(summary.status)")
expect(source).toContain('if (!summary.containerNo) return true')
expect(source).not.toContain('summary?.containerNo && summary.status')
expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。')
expect(source).toContain('该历史 PDA 任务缺少真实集装箱号;补录保存后即可进入任务。')
expect(source).toContain('确认预装前必须补录真实集装箱号。')
expect(source).not.toContain('transportCorrectionNote')
expect(detailSource).not.toContain('class="readiness-note transport-note"')
expect(detailSource).not.toContain('真实集装箱号:</strong>')
expect(source).toContain("isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次'")
expect(source).toContain("isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因')")
expect(source).toContain("String(value || '').trim().toUpperCase()")
@@ -86,24 +86,38 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(detailSource).toContain('role="status"')
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
expect(detailSource).not.toContain('type="success" :closable="false" show-icon title="整份预装计划已确认"')
expect(detailSource).toContain('<el-button type="primary" :loading="confirmSaving"')
expect(detailSource).toContain('type="primary" :loading="confirmSaving"')
expect(detailSource).not.toContain('<el-button type="success" :loading="confirmSaving"')
})
it('keeps explanatory notes outside the item list hidden', () => {
expect(detailSource).not.toContain('核对运输信息、确认预装并维护逐条仓库备注')
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
expect(source).not.toContain('暂无候选,请先配置国内头程 PDA 作业员')
})
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('confirmPreloadPlan(summary.planId, summary.version, assigneeId.value, confirmOperationId.value)')
expect(source).toContain('listDomesticPdaOperators(warehouseId.value)')
expect(source).toContain('v-model="assigneeId"')
expect(source).toContain('<template #pda-operator>')
expect(detailSource).toContain('<slot v-if="mode === \'preload\'" name="pda-operator" />')
expect(source).not.toContain('暂无候选,请先配置国内头程 PDA 作业员')
expect(detailSource).toContain('!pdaAssigneeSelected')
expect(source).toContain('selectedAssignee?.displayName')
expect(source).not.toContain('<el-form-item label="国内头程 PDA 作业员(必选)">')
expect(source).toContain('summary?.readiness.canConfirm')
expect(source).toContain('confirmSaving.value')
expect(source).toContain('await loadDetail()')
expect(source).toContain('本操作将确认整份预装计划')
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm"')
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading"')
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('<OperationErrorToast :details="remarkError || editError || confirmError" />')
expect(source).not.toContain(':message="confirmError.message"')
expect(source).toContain(':message="confirmError.message"')
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
expect(source).toContain("label=\"柜次号\"")
expect(source).toContain("label=\"集装箱号\"")
@@ -12,7 +12,7 @@ import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type Api
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemRemarks, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, listDomesticPdaOperators, updateLoadingItemRemarks, updatePreloadTransport, type DomesticPdaOperator, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
@@ -28,6 +28,10 @@ const confirmOpen = ref(false)
const confirmSaving = ref(false)
const confirmError = ref<ApiErrorDetails | null>(null)
const confirmOperationId = ref('')
const assigneeId = ref('')
const assigneeOptions = ref<DomesticPdaOperator[]>([])
const assigneeLoading = ref(false)
const selectedAssignee = computed(() => assigneeOptions.value.find((operator) => operator.userId === assigneeId.value))
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const remarkOpen = ref(false)
@@ -37,6 +41,7 @@ const remarkTarget = ref<LoadingItem | null>(null)
const remarkForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
const remarkOperationId = ref('')
let loadSequence = 0
let assigneeLoadSequence = 0
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
type LoadingPlanLifecycle = { planId: string | number; planStatus: string; detailResource: 'PRELOAD' | 'LOADING_LIST' }
@@ -49,6 +54,7 @@ async function resolveLoadingPlanLifecycle() {
return unwrapApiResponse(response)
}
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
const canPushPda = computed(() => canEdit.value && access.can('platform:firstleg:pda:assign'))
const canCorrectTransport = computed(() => {
const summary = detail.value?.summary
const pdaStatus = summary?.readiness.pdaTaskStatus
@@ -57,15 +63,6 @@ const canCorrectTransport = computed(() => {
return !pdaStatus || pdaStatus === 'PENDING'
})
const isContainerCorrection = computed(() => Boolean(detail.value?.summary.containerNo))
const transportCorrectionNote = computed(() => {
const summary = detail.value?.summary
if (!summary) return ''
if (!summary.containerNo && summary.readiness.pdaTaskId) return '该历史 PDA 任务缺少真实集装箱号;补录保存后即可进入任务。'
if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。'
if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。'
if (!summary.containerNo) return '确认预装前必须补录真实集装箱号。'
return '真实集装箱号仅可在 PDA 领取前纠错,纠错保存时必须填写操作原因。'
})
const editDialogTitle = computed(() => isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次')
const editRules: FormRules = {
containerNo: [
@@ -117,6 +114,7 @@ async function loadDetail() {
return
}
detail.value = result
if (canPushPda.value && !result.summary.readiness.pdaTaskId) void loadAssigneeOptions()
} catch (cause) {
if (sequence !== loadSequence) return
error.value = getApiErrorDetails(cause, '预装清单详情加载失败,请稍后重试')
@@ -165,6 +163,25 @@ async function saveTransport() {
}
}
async function loadAssigneeOptions() {
const sequence = ++assigneeLoadSequence
assigneeLoading.value = true
try {
if (!warehouseId.value) return
const options = await listDomesticPdaOperators(warehouseId.value)
if (sequence !== assigneeLoadSequence) return
assigneeOptions.value = options
if (assigneeId.value && !assigneeOptions.value.some((operator) => operator.userId === assigneeId.value)) assigneeId.value = ''
confirmError.value = null
} catch (cause) {
if (sequence !== assigneeLoadSequence) return
confirmError.value = getApiErrorDetails(cause, '国内 PDA 作业员加载失败,请重试')
assigneeOptions.value = []
} finally {
if (sequence === assigneeLoadSequence) assigneeLoading.value = false
}
}
function openRemarks(item: LoadingItem) {
if (!canEdit.value || !detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
remarkTarget.value = item
@@ -224,7 +241,7 @@ async function saveRemarks() {
function openConfirmPreload() {
const summary = detail.value?.summary
if (!canEdit.value || !summary?.readiness.canConfirm) return
if (!canPushPda.value || !summary?.readiness.canConfirm || !assigneeId.value) 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
@@ -232,15 +249,15 @@ function openConfirmPreload() {
async function submitConfirmPreload() {
const summary = detail.value?.summary
if (!summary?.readiness.canConfirm || confirmSaving.value) return
if (!summary?.readiness.canConfirm || !canPushPda.value || !assigneeId.value || confirmSaving.value) return
confirmSaving.value = true
confirmError.value = null
try {
const result = await confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)
const result = await confirmPreloadPlan(summary.planId, summary.version, assigneeId.value, confirmOperationId.value)
await loadDetail()
confirmOpen.value = false
confirmOperationId.value = ''
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已具备下发条件' : '预装已确认')
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已推送给指定作业员' : '预装已确认')
} catch (cause) {
confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')
} finally {
@@ -249,14 +266,29 @@ async function submitConfirmPreload() {
}
onMounted(() => void loadDetail())
watch([planId, warehouseId], () => void loadDetail())
watch([planId, warehouseId], () => {
assigneeLoadSequence++
assigneeId.value = ''
assigneeOptions.value = []
assigneeLoading.value = false
void loadDetail()
})
</script>
<template>
<AdminPage>
<div class="preload-detail-page admin-density">
<OperationErrorToast :details="remarkError || editError || confirmError" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-push-pda="canPushPda" :pda-assignee-selected="Boolean(assigneeId)" :can-edit-transport="canCorrectTransport" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload">
<template #pda-operator>
<div v-if="canPushPda && !detail?.summary.readiness.pdaTaskId" class="pda-operator-picker">
<label for="preload-pda-operator">PDA 操作员 <span>(必选)</span></label>
<el-select id="preload-pda-operator" v-model="assigneeId" filterable placeholder="请选择操作员" :loading="assigneeLoading" :disabled="confirmSaving" aria-label="PDA 操作员">
<el-option v-for="operator in assigneeOptions" :key="operator.userId" :value="operator.userId" :label="operator.displayName" />
</el-select>
</div>
</template>
</LoadingListDetailDrawer>
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
@@ -278,7 +310,7 @@ watch([planId, warehouseId], () => void loadDetail())
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认预装并推送 PDA" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
<div class="confirm-content">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
<el-descriptions v-if="detail" :column="1" border>
@@ -286,6 +318,8 @@ watch([planId, warehouseId], () => void loadDetail())
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待补录' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions :column="1" border><el-descriptions-item label="PDA 操作员">{{ selectedAssignee?.displayName || '未选择' }}</el-descriptions-item></el-descriptions>
<LoadErrorState v-if="confirmError" compact title="推送失败" :message="confirmError.message" :show-retry="false" />
</div>
</AppDialog>
</AdminPage>
@@ -296,6 +330,10 @@ watch([planId, warehouseId], () => void loadDetail())
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.confirm-content { display: grid; gap: var(--admin-space-5, 12px); }
.pda-operator-picker { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; }
.pda-operator-picker label { flex: 0 0 auto; font-size: var(--admin-font-body, 13px); white-space: nowrap; }
.pda-operator-picker label span { color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
.pda-operator-picker :deep(.el-select) { width: 180px; }
.remarks-form { margin-top: 14px; }
.remarks-form small { display: block; margin-top: var(--admin-space-1, 4px); color: var(--cc-muted); }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }