164 lines
17 KiB
Vue
164 lines
17 KiB
Vue
<script setup lang="ts">
|
||
import './appointmentNativeForms.css'
|
||
import '@/styles/adminWorkbench.css'
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { useAuthStore } from '@/core/auth/auth'
|
||
import { getDispatch, createDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, dispatchVehicleSpecs, DispatchInputError, requireBatchVersions, expandSelectionGroups, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchDetail, type Candidate } from '@/modules/overseas-warehouse'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||
import { getReceivingContext } from './receivingApi'
|
||
import { useWorkDraft } from './useWorkDraft'
|
||
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
||
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
|
||
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
|
||
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort())
|
||
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
||
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
|
||
const { page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
|
||
const loading = computed(() => working.value || candidatePage.loading.value)
|
||
const selectedRows = ref<Record<string, BatchRow>>({})
|
||
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
|
||
let nativeGeneration = 0
|
||
const createdNo = ref('')
|
||
const capacity = ref(30), result = ref<DispatchDetail | null>(null)
|
||
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
|
||
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
|
||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
|
||
const writable = computed(() => props.canWrite && canCreate.value && (!existing.value.length || existingReady.value))
|
||
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
|
||
const unresolved = computed(() => saving.value)
|
||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !createdNo.value), unresolved)
|
||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||
let generation = 0
|
||
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
|
||
function reasons(row: BatchRow) { return row.blockReasons }
|
||
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||
async function load() {
|
||
if (!readable.value) return
|
||
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
||
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
|
||
try {
|
||
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
|
||
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
|
||
if (current !== generation) return
|
||
const references: typeof existing.value = []
|
||
receiving.forEach((context, index) => {
|
||
const batchKey = batches[index]!
|
||
if (context.batch.batchKey !== batchKey) throw new DispatchInputError('运输关系与当前预约批次不一致,请重新读取')
|
||
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
|
||
if (!vehicle.dispatchNo) throw new DispatchInputError('本批已有运输安排但缺少排车单号,请核对原记录')
|
||
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
|
||
}
|
||
})
|
||
existing.value = references
|
||
if (references.length && !arrangingRemaining.value) return
|
||
const origins = [...new Set(receiving.map(context => context.batch.originWarehouseId).filter(Boolean))]
|
||
if (origins.length !== 1 || receiving.some(context => !context.batch.originWarehouseId)) throw new DispatchInputError('所选批次须有明确且相同的起运仓,请从同仓批次继续')
|
||
warehouseId.value = origins[0]!
|
||
const batchCandidates = await Promise.all(batches.map(key => getBatchDispatchCandidates(key)))
|
||
if (current !== generation) return
|
||
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
|
||
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
|
||
contexts.value = batchCandidates; receivingContexts = receiving
|
||
await loadCandidatePage(true)
|
||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||
finally { if (current === generation) working.value = false }
|
||
}
|
||
async function loadCandidatePage(autoSelect = false) {
|
||
const sequence = ++nativeGeneration, current = generation
|
||
const planIds = [...new Set(contexts.value.flatMap(context => context.candidates.map(row => row.planId)))]
|
||
if (!planIds.length) { candidatePage.reset(); rows.value = []; return }
|
||
await candidatePage.load({ warehouseId: warehouseId.value, planIds, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
|
||
if (sequence !== nativeGeneration || current !== generation) return
|
||
if (error.value) { rows.value = []; return }
|
||
try {
|
||
requireBatchVersions(candidatePage.candidates.value.flatMap(row => row.appointmentBatches))
|
||
rows.value = contexts.value.flatMap((context, index) => context.candidates.map(row => {
|
||
const matches = candidatePage.candidates.value.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
|
||
const candidate = matches.length === 1 ? matches[0]! : null
|
||
const sources = receivingContexts[index]!.batch.items
|
||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
||
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
||
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
||
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
||
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
|
||
}))
|
||
if (candidateTotal.value > candidatePageSize.value) rows.value = rows.value.filter(row => row.native)
|
||
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
|
||
if (autoSelect && candidateTotal.value <= candidatePageSize.value) {
|
||
const eligible = rows.value.filter(row => !reasons(row).length)
|
||
selected.value = props.batchKeys.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
||
selectedRows.value = Object.fromEntries(eligible.filter(row => selected.value.includes(row.key)).map(row => [row.key, clone(row)]))
|
||
baseline.value = JSON.stringify(selected.value)
|
||
}
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||
}
|
||
function changeCandidatePage(page: number) { candidatePageNumber.value = page; void loadCandidatePage() }
|
||
function changeCandidateSize(size: number) { candidatePageSize.value = size; candidatePageNumber.value = 1; void loadCandidatePage() }
|
||
function select(row: BatchRow, event: Event) {
|
||
const input = event.target as HTMLInputElement
|
||
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
|
||
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
|
||
else if (!selected.value.includes(row.key)) {
|
||
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
|
||
else { selectedRows.value[row.key] = clone(row); selected.value.push(row.key) }
|
||
}
|
||
input.checked = selected.value.includes(row.key)
|
||
}
|
||
async function save() {
|
||
if (saving.value || loading.value || !canCreate.value) return
|
||
saveError.value = null; saving.value = true
|
||
try {
|
||
ensureOwner()
|
||
if (!writable.value || error.value || createdNo.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
|
||
const body = { publishLooseboardDemand: false, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
|
||
const proof = await createDispatch(body); ensureOwner()
|
||
validateCommandResult(proof, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: body.idempotencyKey } })
|
||
createdNo.value = proof.dispatchNo; selected.value = []; baseline.value = '[]'; emit('updated')
|
||
const latest = await getDispatch(proof.dispatchNo); ensureOwner(); validateCurrentDispatch(latest, proof)
|
||
result.value = latest; selected.value = []; baseline.value = '[]'
|
||
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`)
|
||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车创建失败,请刷新核对当前货物后重新操作') }
|
||
finally { saving.value = false }
|
||
}
|
||
async function remaining() { if (!unresolved.value && !addingBusy.value) { createdNo.value = ''; result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||
onMounted(load)
|
||
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
|
||
</script>
|
||
<template>
|
||
<section class="work-form appointment-native-form" aria-label="按预约批次创建整车排车">
|
||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
||
<template v-if="existing.length && !loading && !error && !createdNo">
|
||
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
|
||
</template>
|
||
<template v-if="createdNo"><p>已创建整车排车单:{{ createdNo }}</p><el-button @click="emit('external', detailPath(createdNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result?.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="createdNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
||
<template v-else-if="readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
|
||
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
|
||
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved" class="workbench-input"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
|
||
<el-pagination aria-label="批次关联候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="unresolved || loading" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
|
||
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
|
||
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
||
</template>
|
||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||
</section>
|
||
</template>
|