Files
admin-web/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue
T

196 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { allocateWarehouseTransfer, getWarehouseTransfer, type AllocateWarehouseTransferPayload, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
import { listWarehouseTransferSources, type WarehouseTransferSourceCandidate } from '../api/warehouseTransferSources'
import { lastMileStatusLabel, relabelApplicationStatusLabel } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import WarehouseTransferMovementPanel from './WarehouseTransferMovementPanel.vue'
const props = defineProps<{ customerId: string; transferNo: string }>()
const router = useRouter(), access = useAccessPreviewStore()
const detail = ref<WarehouseTransferDetailDto | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let request = 0
let sourceRequest = 0
const allocationOpen = ref(false), sourceLoading = ref(false), allocationSaving = ref(false)
const sourceError = ref<ApiErrorDetails | null>(null), allocationError = ref<ApiErrorDetails | null>(null)
const sourceRows = ref<WarehouseTransferSourceCandidate[]>([]), sourceIssues = ref<string[]>([])
const selected = ref<string[]>([]), quantities = ref<Record<string, number>>({})
const originalCommand = ref<AllocateWarehouseTransferPayload | null>(null)
const allocationRejected = ref(false), previousRejection = ref<ApiErrorDetails | null>(null)
function isDefiniteAllocationRejection(cause: unknown) {
const rejectionCodes = ['A0400', 'A0404', 'A0409', 'A0422']
const knownStatus = (status: number | undefined) => status === undefined || [200, 400, 404, 409, 422].includes(status)
if (cause instanceof ApiResponseError) return rejectionCodes.includes(cause.code) && knownStatus(cause.status)
if (axios.isAxiosError<{ code?: string }>(cause)) return !!cause.response && knownStatus(cause.response.status) && rejectionCodes.includes(cause.response.data?.code || '')
return false
}
const canAllocate = computed(() => access.can('lastleg:warehouse-transfer:manage') && access.can(permission.inventoryList) && (detail.value?.status === 'APPROVED' || originalCommand.value !== null))
const selectedPackages = computed(() => selected.value.reduce((sum, id) => sum + (quantities.value[id] || 0), 0))
const selectionValid = computed(() => !!detail.value && !sourceIssues.value.length && selected.value.length > 0 && selectedPackages.value === detail.value.packageCount && selected.value.every(id => {
const row = sourceRows.value.find(item => item.inventoryPositionId === id), count = quantities.value[id] || 0
return row?.canSelect && Number.isSafeInteger(count) && count > 0 && count <= row.availablePackages
}))
const sourceColumns: DataTableColumn[] = [
{ key: 'selection', label: '选择', width: 70, fixed: 'left', slotName: 'sourceSelection' },
{ key: 'inboundCode', prop: 'inboundCode', label: '来源入仓码', minWidth: 160 },
{ key: 'palletNo', prop: 'palletNo', label: '托盘码', minWidth: 160 },
{ key: 'warehouseLocationCode', prop: 'warehouseLocationCode', label: '库位', minWidth: 110 },
{ key: 'skuCode', prop: 'skuCode', label: '实际旧 SKU', minWidth: 130 },
{ key: 'productNameZh', prop: 'productNameZh', label: '品名', minWidth: 130 },
{ key: 'availablePackages', prop: 'availablePackages', label: '当前可分配', width: 100 },
{ key: 'quantity', label: '本次箱数', width: 130, slotName: 'sourceQuantity' },
{ key: 'blockReason', prop: 'blockReason', label: '不可选择原因', minWidth: 200 },
]
async function openAllocation() {
if (!canAllocate.value) return
allocationOpen.value = true
if (!originalCommand.value) await loadSources()
}
async function loadSources() {
if (originalCommand.value) return
if (!detail.value) {
await load()
if (error.value) { sourceError.value = error.value; return }
}
if (!detail.value || !canAllocate.value) return
const current = ++sourceRequest
sourceLoading.value = true; sourceError.value = null; allocationError.value = null
sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
try {
const result = await listWarehouseTransferSources(props.customerId, detail.value)
if (current === sourceRequest) { sourceRows.value = result.candidates; sourceIssues.value = result.issues }
} catch (cause) { if (current === sourceRequest) sourceError.value = getApiErrorDetails(cause, '来源库存加载失败,请重试') }
finally { if (current === sourceRequest) sourceLoading.value = false }
}
function selectSource(id: string, value: boolean) {
const row = sourceRows.value.find(candidate => candidate.inventoryPositionId === id)
if (!row?.canSelect || originalCommand.value) return
selected.value = value ? [...new Set([...selected.value, row.inventoryPositionId])] : selected.value.filter(id => id !== row.inventoryPositionId)
if (value && !quantities.value[row.inventoryPositionId]) quantities.value[row.inventoryPositionId] = 1
}
async function confirmAllocation() {
if (allocationSaving.value || allocationRejected.value || !canAllocate.value || (!originalCommand.value && !selectionValid.value)) return
if (!originalCommand.value) originalCommand.value = { idempotencyKey: crypto.randomUUID(), allocations: selected.value.map(id => ({ inventoryPositionId: id, palletId: sourceRows.value.find(row => row.inventoryPositionId === id)!.palletId, packageCount: quantities.value[id]! })) }
const customerId = props.customerId, transferNo = props.transferNo, command = originalCommand.value
allocationSaving.value = true; allocationError.value = null
try {
const value = await allocateWarehouseTransfer(customerId, transferNo, command)
if (customerId !== props.customerId || transferNo !== props.transferNo) return
if (value.transferNo !== transferNo) throw new ApiResponseError('分配响应与当前转运单不一致,请使用原操作核查。', 'TRANSFER_IDENTITY_MISMATCH')
detail.value = value; originalCommand.value = null; allocationOpen.value = false
previousRejection.value = null
} catch (cause) {
if (customerId !== props.customerId || transferNo !== props.transferNo) return
allocationRejected.value = isDefiniteAllocationRejection(cause)
allocationError.value = getApiErrorDetails(cause, '库存分配结果尚未确认,已保留本次选择,重试使用相同内容')
}
finally { allocationSaving.value = false }
}
async function reselectSources() {
if (!allocationRejected.value || allocationSaving.value || !canAllocate.value) return
previousRejection.value = allocationError.value
allocationRejected.value = false; originalCommand.value = null; allocationError.value = null
sourceRequest++; sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
await load()
if (error.value) { sourceError.value = error.value; return }
if (detail.value?.status !== 'APPROVED') {
sourceError.value = { message: '当前转运状态已变化,请核对最新履约后再分配库存。' }
return
}
await loadSources()
}
const fields = computed(() => detail.value ? [
['转运单号', detail.value.transferNo], ['转运状态', lastMileStatusLabel(detail.value.status)],
['需要换标', detail.value.needRelabel ? '是' : '否'], ['关联换标单', detail.value.relabelNo || '未关联'],
['换标状态', detail.value.relabelStatus ? relabelApplicationStatusLabel(detail.value.relabelStatus) : '—'],
] : [])
const gateText = computed(() => {
if (!detail.value?.needRelabel) return ''
if (!detail.value.relabelNo) return '尚未关联换标单。须先确认本票库存来源并分配,再按关联换标单完成 PDA 作业、复核和客户确认;不得提前出库。'
if (!['READY_FOR_OUTBOUND', 'COMPLETED'].includes(detail.value.relabelStatus || '')) return '本票换标尚未完成复核和客户确认,不得提前出库。请沿关联换标单查看 PDA 作业及客户确认进度。'
return '换标已完成。后续出库仍须按转运单与服务端库存门禁执行。'
})
const allocationRows = computed(() => (detail.value?.allocations || []).map(row => ({ ...row, skuCode: row.skuCode && row.skuCode !== 'MIXED' ? row.skuCode : '—', movementStatus: ({ RESERVED: '已分配', SHIPPED: '已发运', RECEIVED: '目标已实收' } as Record<string, string>)[row.status] || '—' })))
const columns: DataTableColumn[] = [
{ key: 'inboundCode', prop: 'inboundCode', label: '来源入仓码', minWidth: 160 },
{ key: 'movementStatus', prop: 'movementStatus', label: '履约状态', width: 120, afterColumn: 'inboundCode' },
{ key: 'palletNo', prop: 'palletNo', label: '来源托盘码', minWidth: 180 },
{ key: 'skuCode', prop: 'skuCode', label: '实际 SKU', minWidth: 150 },
{ key: 'packageCount', prop: 'packageCount', label: '分配箱数', width: 100 },
]
async function load() {
const current = ++request
detail.value = null; error.value = null; loading.value = false
if (!props.customerId || !props.transferNo) { error.value = { message: '本票缺少客户或来源转运单号,无法读取转运履约。' }; return }
loading.value = true
try {
const value = await getWarehouseTransfer(props.customerId, props.transferNo)
if (current !== request) return
if (value.transferNo !== props.transferNo) throw new ApiResponseError('返回的转运单与当前票不一致,请重新加载。', 'TRANSFER_IDENTITY_MISMATCH')
detail.value = value
} catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '转运履约加载失败,请重试') }
finally { if (current === request) loading.value = false }
}
function openRelabel() {
if (detail.value?.relabelNo && access.can(permission.relabelOrderList)) void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: detail.value.relabelNo } })
}
watch(() => [props.customerId, props.transferNo], () => {
sourceRequest++; allocationOpen.value = false; originalCommand.value = null
sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
sourceError.value = null; allocationError.value = null
allocationRejected.value = false; previousRejection.value = null
void load()
}, { immediate: true })
onBeforeUnmount(() => { request++; sourceRequest++ })
</script>
<template>
<section class="transfer-fulfillment" aria-label="本票转运与换标履约" :aria-busy="loading">
<div class="transfer-fulfillment-heading"><h2>转运与换标履约</h2><div><el-button v-if="canAllocate" @click="openAllocation">{{ allocationRejected ? '查看分配问题' : originalCommand ? '重试本次库存分配' : '选择库存来源 / 分配' }}</el-button><el-button :loading="loading" :disabled="allocationSaving" @click="load">刷新转运状态</el-button></div></div>
<LoadErrorState v-if="error" compact title="转运履约加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<dl class="density-fields"><div v-for="[label, value] in fields" :key="label"><dt>{{ label }}</dt><dd>{{ value }}</dd></div></dl>
<p v-if="gateText" role="status">{{ gateText }}</p>
<el-button v-if="detail.relabelNo && access.can(permission.relabelOrderList)" @click="openRelabel">查看本票换标单</el-button>
<div class="workbench-table"><DataTable :data="allocationRows" :columns="columns" row-key="inventoryPositionId" :show-pagination="false" empty-text="尚未确认本票库存来源分配" /></div>
<WarehouseTransferMovementPanel :customer-id="customerId" :detail="detail" @updated="detail = $event" />
</template>
</section>
<AppDialog v-model="allocationOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`选择本票库存来源 · ${transferNo || '—'}`" width="min(1320px, calc(100vw - 40px))" fullscreenable :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="库存分配核对"><h3 class="density-dialog-section__title">分配核对</h3>
<table class="density-table"><tbody><tr><th scope="row">转运单号</th><td>{{ transferNo || '—' }}</td><th scope="row">本票箱数</th><td>{{ detail?.packageCount ?? '—' }} 箱</td><th scope="row">已选择</th><td>{{ selectedPackages }} 箱</td></tr></tbody></table>
</section>
<el-alert v-if="originalCommand && !allocationRejected" role="status" type="warning" :closable="false" show-icon title="分配结果尚未确认,已保留本次选择,重试使用相同内容。" />
<el-alert v-if="allocationRejected" role="status" type="warning" :closable="false" show-icon title="本次分配未成功,请重新选择来源并核对当前可分配箱数后再次提交。" />
<section v-if="previousRejection" class="density-dialog-section" data-tone="source" aria-label="上次分配诊断"><h3 class="density-dialog-section__title">上次分配诊断</h3><p class="density-dialog-note">上次分配未成功:{{ previousRejection.message }}</p><ApiErrorMeta :details="previousRejection" /></section>
<LoadErrorState v-if="sourceError" compact title="来源库存加载失败" :message="sourceError.message" @retry="loadSources"><template #details><ApiErrorMeta :details="sourceError" /></template></LoadErrorState>
<LoadErrorState v-if="allocationError" compact :title="allocationRejected ? '分配未成功' : '分配结果未确认'" :message="allocationError.message" :loading="allocationSaving || sourceLoading || loading" :retry-text="allocationRejected ? '重新选择来源' : '重试本次分配'" @retry="allocationRejected ? reselectSources() : confirmAllocation()"><template #details><ApiErrorMeta :details="allocationError" /></template></LoadErrorState>
<el-alert v-for="issue in sourceIssues" :key="issue" role="status" :title="issue" type="warning" :closable="false" show-icon />
<section v-if="!sourceError" class="density-dialog-section" data-tone="source" aria-label="本票来源库存"><h3 class="density-dialog-section__title">来源库存</h3>
<div class="workbench-table"><DataTable :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" max-height="" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
<template #sourceSelection="{ row }"><el-checkbox :model-value="selected.includes(row.inventoryPositionId)" :aria-label="`选择 ${row.palletNo} ${row.skuCode}`" :disabled="!row.canSelect || !!originalCommand" @update:model-value="selectSource(String(row.inventoryPositionId), Boolean($event))" /></template>
<template #sourceQuantity="{ row }"><el-input-number :model-value="quantities[row.inventoryPositionId] || 0" :aria-label="`${row.palletNo} 分配箱数`" :min="0" :max="Math.max(0, row.availablePackages)" :precision="0" :step="1" placeholder="请输入分配箱数" :disabled="!selected.includes(row.inventoryPositionId) || !!originalCommand" controls-position="right" @update:model-value="quantities[row.inventoryPositionId] = Number($event || 0)" /></template>
</DataTable></div>
</section>
</div>
</AppDialog>
</template>
<style scoped>
.transfer-fulfillment { padding-bottom: var(--admin-space-5); }
.transfer-fulfillment-heading { display: flex; align-items: center; justify-content: space-between; }
.transfer-fulfillment-heading h2 { margin: 0; font-size: var(--admin-font-section); }
.transfer-fulfillment p { color: var(--workbench-muted); }
</style>