113 lines
9.5 KiB
Vue
113 lines
9.5 KiB
Vue
<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>
|