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

113 lines
9.5 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 { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getDispatchAppointmentAllocations, saveDispatchAppointmentAllocations } from '../api/dispatches'
import { DispatchInputError, requireVersion, type AppointmentAllocations, type AppointmentAllocationItem, type AppointmentAllocationSource, type AppointmentAllocationOption, type DispatchBatchAllocation } from '../dispatchContracts'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
interface Choice extends AppointmentAllocationOption { packages: number; unavailable: boolean }
interface SourceRow { key: string; item: AppointmentAllocationItem; source: AppointmentAllocationSource; choices: Choice[] }
const context = ref<AppointmentAllocations | null>(null), rows = ref<SourceRow[]>([])
const loading = ref(false), saving = ref(false), factsReady = ref(false), error = ref<ApiErrorDetails | null>(null)
let generation = 0
const permitted = computed(() => access.can('platform:overseas-warehouse:dispatch:edit') && context.value?.edit.allowed === true)
const allocationKey = (item: AppointmentAllocationItem, source: AppointmentAllocationSource, batch: string) => JSON.stringify([item.dispatchItemId, source.sourceKey, source.destinationKey, batch])
const quotaKey = (row: SourceRow, batch: string) => JSON.stringify([batch, row.item.customerId, row.source.sourceKey, row.source.destinationKey])
const validation = computed(() => {
const used = new Map<string, number>()
for (const row of rows.value) {
if (row.source.blockReasons.length) return row.source.blockReasons[0]!
if (!Number.isSafeInteger(row.source.packageCount) || (row.source.packageCount ?? 0) <= 0) return '本票箱数尚未核实,请刷新本车货物'
let total = 0
for (const choice of row.choices) {
if (!Number.isSafeInteger(choice.packages) || choice.packages < 0) return '分配箱数须为非负整数'
if (!choice.packages) continue
if (choice.unavailable) return '原选择的预约已失效,请将对应箱数清零后重新选择'
total += choice.packages
const key = quotaKey(row, choice.batchKey), count = (used.get(key) || 0) + choice.packages
if (count > choice.availablePackages) return `预约 ${choice.batchNo} 的本票剩余可排量不足,请调整分配箱数`
used.set(key, count)
}
if (total !== row.source.packageCount) return `托盘 ${row.item.palletNo} 的本票货物须完整分配 ${row.source.packageCount} 箱`
}
return rows.value.length ? '' : '本车暂无可关联正式预约的FBA货物'
})
const canSave = computed(() => factsReady.value && permitted.value && !loading.value && !saving.value && !validation.value)
async function load(preserve = false) {
const current = ++generation, no = props.dispatchNo
const previous = new Map(rows.value.flatMap(row => row.choices.map(choice => [allocationKey(row.item, row.source, choice.batchKey), choice] as const)))
loading.value = true; factsReady.value = false
if (!preserve) { error.value = null; context.value = null; rows.value = [] }
try {
const value = await getDispatchAppointmentAllocations(no)
if (current !== generation) return
if (value.dispatchNo !== no) throw new DispatchInputError('预约分配与当前排车单不一致,请重新打开')
requireVersion(value.recordVersion)
const next: SourceRow[] = []
for (const item of value.items) for (const source of item.sources.filter(source => source.destinationKey.startsWith('FBA:'))) {
const key = JSON.stringify([item.dispatchItemId, source.sourceKey, source.destinationKey])
const choices: Choice[] = source.options.map(option => {
const retained = item.allocations.find(a => a.batchKey === option.batchKey && a.sourceKey === source.sourceKey && a.destinationKey === source.destinationKey)
const old = preserve ? previous.get(allocationKey(item, source, option.batchKey)) : undefined
return { ...option, packages: old?.packages ?? retained?.packageCount ?? (!preserve && !item.allocations.length && source.options.length === 1 ? source.packageCount || 0 : 0), unavailable: false }
})
if (preserve) for (const oldRow of rows.value.filter(row => row.key === key)) for (const old of oldRow.choices)
if (old.packages > 0 && !choices.some(choice => choice.batchKey === old.batchKey)) choices.push({ ...old, unavailable: true })
next.push({ key, item, source, choices })
}
context.value = value; rows.value = next; factsReady.value = true
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本车正式预约读取失败,请刷新重试') }
finally { if (current === generation) loading.value = false }
}
async function save() {
if (!canSave.value || !context.value) return
const current = generation, no = props.dispatchNo
saving.value = true; error.value = null
try {
const batchAllocations: DispatchBatchAllocation[] = rows.value.flatMap(row => row.choices.filter(choice => choice.packages > 0).map(choice => ({
planPalletId: row.item.planPalletId, customerId: row.item.customerId, sourceKey: row.source.sourceKey, destinationKey: row.source.destinationKey,
batchKey: choice.batchKey, recordVersion: requireVersion(choice.recordVersion), packageCount: choice.packages,
})))
await saveDispatchAppointmentAllocations(no, { recordVersion: requireVersion(context.value.recordVersion), idempotencyKey: crypto.randomUUID(),
dispatchItemIds: [...new Set(rows.value.map(row => row.item.dispatchItemId))], batchAllocations })
if (current !== generation) return
ElMessage.success('正式预约已关联,请核对运输时间后重新提交审核')
emit('saved'); open.value = false
} catch (cause) {
if (current === generation) { error.value = getApiErrorDetails(cause, '正式预约关联未完成'); await load(true) }
} finally { saving.value = false }
}
watch([open, () => props.dispatchNo], ([value]) => { if (value) void load(); else { generation++; factsReady.value = false } }, { immediate: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="`关联正式预约 · ${dispatchNo}`" width="1100px" fullscreenable
confirm-text="保存关联" :loading="loading" :confirm-loading="saving" :confirm-disabled="!canSave" :close-on-press-escape="!saving" @confirm="save">
<LoadErrorState v-if="error" title="正式预约关联未完成" :message="error.message" :loading="loading || saving" retry-text="刷新当前预约" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="context && !permitted" :title="context.edit.blockReason || '当前无关联预约资格'" type="warning" :closable="false" show-icon />
<p class="density-dialog-note">按本车每托、每票货物核对已确认的正式预约及箱数。保存后保留原排车,重新提交两级审核。</p>
<div class="appointment-table"><table class="density-table density-table--editable"><thead><tr><th>客户/托盘</th><th>订单号/入仓码</th><th>目的仓</th><th>本票箱数</th><th>正式预约/当地送达时间</th><th>本票可排箱数</th><th>分配箱数</th></tr></thead><tbody>
<template v-for="row in rows" :key="row.key">
<tr v-for="choice in row.choices" :key="choice.batchKey"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td>{{ choice.batchNo || '—' }}<span v-if="choice.unavailable">(已失效)</span><br>{{ choice.confirmationNo || '—' }}<br>{{ choice.appointmentAt?.replace('T', ' ') }}<template v-if="choice.appointmentEndAt"> ~ {{ choice.appointmentEndAt.replace('T', ' ') }}</template><br>{{ choice.timezone }}</td><td>{{ choice.availablePackages }}<span v-if="!choice.unavailable">(同票共用)</span></td><td><el-input-number v-model="choice.packages" :min="0" :precision="0" :controls="false" :disabled="loading || saving || !permitted" :aria-label="`${row.item.palletNo} ${choice.batchNo} 分配箱数`" /></td></tr>
<tr v-if="!row.choices.length"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td colspan="3">{{ row.source.blockReasons.join(';') || '暂无已确认的正式预约,请先在预约列表确认送仓时间' }}</td></tr>
</template><tr v-if="!loading && !rows.length"><td colspan="7">本车暂无可关联正式预约的FBA货物</td></tr>
</tbody></table></div>
<el-alert v-if="factsReady && permitted && validation" :title="validation" type="warning" :closable="false" show-icon />
</AppDialog>
</template>
<style scoped>
.appointment-table { overflow-x: auto; }
.appointment-table table { min-width: 960px; }
.appointment-table :deep(.el-input-number) { width: 112px; }
</style>