feat: confirm container inbound plans

This commit is contained in:
2026-09-09 00:44:07 +08:00
parent 695db084e2
commit cf8727a809
5 changed files with 243 additions and 27 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, Eye, Pencil } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
@@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { changeContainerReceivingWarehouse, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -75,6 +75,22 @@ const selectedContainerBookingId = ref('')
const containerPreview = ref<ContainerInboundPlanPreviewDto | null>(null)
const containerPreviewLoading = ref(false)
const containerPreviewError = ref('')
type ContainerConversionPlan = Awaited<ReturnType<typeof convertLastMileContainerToInboundPlans>>[number]
interface ContainerConversionResult {
plans: ContainerConversionPlan[]
existingPlanNos: Set<string>
newPlanCount: number
existingPlanCount: number
}
const containerConversionResult = ref<ContainerConversionResult | null>(null)
let containerPreviewRequest = 0
let containerPreviewLoadingBookingId = ''
let containerConversionIdempotencyKey = ''
const containerConfirmDisabled = computed(() => !containerPreview.value?.ready
|| containerPreview.value.pendingOrderCount <= 0
|| containerPreviewLoading.value
|| actionLoading.value
|| Boolean(containerConversionResult.value))
const receivingWarehouseOpen = ref(false)
const receivingWarehouseLoading = ref(false)
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
@@ -548,6 +564,8 @@ function openContainerConversion() {
containerLoadError.value = null
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
actionError.value = null
containerConvertOpen.value = true
void loadContainerOptions()
@@ -558,21 +576,76 @@ function openContainerConversionFor(container: ContainerPlanProgressSummaryDto)
containerLoadError.value = null
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
actionError.value = null
containerConvertOpen.value = true
void loadContainerPreview(container.sourceContainerBookingId)
}
async function loadContainerPreview(sourceContainerBookingId = selectedContainerBookingId.value) {
async function loadContainerPreview(sourceContainerBookingId = selectedContainerBookingId.value, preserveResult = false) {
if (containerPreviewLoading.value && containerPreviewLoadingBookingId === sourceContainerBookingId) return
if (containerPreview.value?.sourceContainerBookingId === sourceContainerBookingId) return
const request = ++containerPreviewRequest
containerPreview.value = null
containerPreviewError.value = ''
if (!sourceContainerBookingId) return
if (!preserveResult) {
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
}
if (!sourceContainerBookingId) {
containerPreviewLoading.value = false
containerPreviewLoadingBookingId = ''
return
}
containerPreviewLoading.value = true
containerPreviewLoadingBookingId = sourceContainerBookingId
try {
containerPreview.value = await getContainerInboundPlanPreview(sourceContainerBookingId)
const preview = await getContainerInboundPlanPreview(sourceContainerBookingId)
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
containerPreview.value = preview
if (!preserveResult) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
} catch (error) {
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
containerPreviewError.value = getApiErrorDetails(error, '加载整柜入库计划预览失败,请重试').message
} finally {
containerPreviewLoading.value = false
if (request === containerPreviewRequest) {
containerPreviewLoading.value = false
containerPreviewLoadingBookingId = ''
}
}
}
async function confirmContainerInboundPlans() {
const preview = containerPreview.value
if (!preview?.ready || preview.pendingOrderCount <= 0 || actionLoading.value) return
if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
const bookingId = preview.sourceContainerBookingId
const existingPlanNos = new Set(preview.groups.flatMap((group) => group.orders)
.filter((order) => order.readiness === 'PLANNED' && order.existingPlanNo)
.map((order) => String(order.existingPlanNo)))
actionLoading.value = true
actionError.value = null
try {
const plans = await convertLastMileContainerToInboundPlans({
sourceContainerBookingId: bookingId,
snapshotVersion: preview.snapshotVersion,
snapshotHash: preview.snapshotHash,
}, containerConversionIdempotencyKey)
if (selectedContainerBookingId.value !== bookingId || !containerConvertOpen.value) return
const existingPlanCount = plans.filter((plan) => existingPlanNos.has(plan.planNo)).length
containerConversionResult.value = {
plans,
existingPlanNos,
newPlanCount: plans.length - existingPlanCount,
existingPlanCount,
}
ElMessage.success(`已生成 ${plans.length - existingPlanCount} 个入库计划`)
containerPreview.value = null
await Promise.all([load(), loadContainerPreview(bookingId, true)])
} catch (error) {
actionError.value = getApiErrorDetails(error, '按柜生成入库计划失败,请使用原操作重试')
} finally {
actionLoading.value = false
}
}
@@ -615,10 +688,22 @@ function blockerText(blockers: string[]) {
DESTINATION_NOT_READY: '目的地未就绪',
CARGO_FACT_INVALID: '货物数据无效',
DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照',
PALLET_COUNT_NOT_READY: '计划托数暂未就绪',
}
return blockers.map((code) => labels[code] || code).join(';') || '—'
return [...new Set(blockers.map((code) => labels[code] || '计划数据暂未就绪,请刷新或联系管理员'))].join(';') || '—'
}
watch(containerConvertOpen, (open) => {
if (open) return
containerPreviewRequest += 1
containerPreviewLoadingBookingId = ''
containerPreviewLoading.value = false
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
})
function openDetail(source: object) {
const row = source as LastMileOrderListItem
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
@@ -703,14 +788,14 @@ onMounted(() => { void load() })
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" confirm-text="确认生成(接口待就绪)" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-disabled="true">
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因。当前仅开放预览,确认接口就绪前不会提交生成请求。</p>
<AppDialog v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConversionResult ? '已生成' : '确认生成'" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form-item label="选择待分配柜" required>
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" />
</el-select>
</el-form-item>
@@ -728,7 +813,17 @@ onMounted(() => { void load() })
<div class="container-preview__metric" :class="{ 'container-preview__metric--danger': containerPreview.blockedOrderCount > 0 }"><span>阻断订单</span><strong>{{ containerPreview.blockedOrderCount }}</strong></div>
</div>
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else class="container-convert-alert" type="warning" :closable="false" show-icon title="预览已就绪;确认接口完成接入前,暂不开放提交。" />
<el-alert v-else-if="!containerConversionResult" class="container-convert-alert" type="success" :closable="false" show-icon :title="`预览已就绪,本次将新增 ${containerPreview.pendingOrderCount} 个计划、${containerPreview.pendingPalletCount} 个托盘码。`" />
<section v-if="containerConversionResult" class="container-conversion-result">
<header><strong>生成完成</strong><span>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
<div class="container-conversion-result__plans">
<article v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan">
<div><strong class="text-ellipsis" :title="plan.planNo">{{ plan.planNo }}</strong><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></div>
<p v-if="plan.pallets.length"><span v-for="pallet in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet" :title="pallet.palletNo || '托盘码暂未返回'">{{ pallet.palletNo || '—' }}</span></p>
<p v-else class="container-conversion-result__empty">暂无托盘码</p>
</article>
</div>
</section>
<div class="container-preview__scroll">
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
<header class="container-preview__group-header">
@@ -839,6 +934,14 @@ onMounted(() => { void load() })
.container-preview__group + .container-preview__group { margin-top: 16px; }
.container-preview__group-header { min-height: 42px; padding: 0 14px; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
.container-preview__group-header strong { min-width: 0; color: var(--cc-text); }
.container-conversion-result { margin-top: 14px; padding: 14px; border: 1px solid #86efac; border-radius: 8px; background: #f0fdf4; }
.container-conversion-result > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #166534; }
.container-conversion-result__plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.container-conversion-result__plan { min-width: 0; padding: 10px 12px; border: 1px solid #bbf7d0; border-radius: 6px; background: #fff; }
.container-conversion-result__plan > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.container-conversion-result__plan p { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
.container-conversion-result__pallet { max-width: 100%; padding: 3px 7px; overflow: hidden; border-radius: 4px; background: #dcfce7; color: #166534; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.container-conversion-result__empty { color: var(--cc-muted); font-size: 12px; }
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }