Files
admin-web/src/modules/appointment-calendar/AppointmentBatchForm.vue
T

134 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 { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getAppointmentBatch, repartitionAppointments, allocation, allocationKey, mergeBatchSources, splitBatch, type FbaAppointmentBatchContext, type RepartitionFbaAppointmentsRequest, type RepartitionFbaAppointmentsResponse } from './batchApi'
import { getWorkItems, type WorkPage } from './workApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
import CargoVerificationForm from './CargoVerificationForm.vue'
import type { CargoVerificationSource } from './cargoVerificationApi'
const props = defineProps<{ batchKey: string }>()
const emit = defineEmits<{ updated: []; appointment: [appointmentNo: string] }>()
const access = useAccessPreviewStore(), activeKey = ref(props.batchKey), context = ref<FbaAppointmentBatchContext | null>(null), other = ref<FbaAppointmentBatchContext | null>(null)
const mode = ref<'split' | 'merge'>('split'), loading = ref(false), saving = ref(false), ready = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<RepartitionFbaAppointmentsRequest | null>(null), result = ref<RepartitionFbaAppointmentsResponse | null>(null)
const form = reactive({ firstDate: '', nextDate: '', reason: '', remark: '', moved: {} as Record<string, number | string> })
const baseline = ref(JSON.stringify(form)), verification = ref<CargoVerificationSource | null>(null)
const canLeave = useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
const candidates = ref<WorkPage | null>(null), candidatePage = ref(1), keyword = ref(''), candidateLoading = ref(false), candidateError = ref<ApiErrorDetails | null>(null)
let candidateRequest = 0
let initializedKey: string | null = null
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
const replaced = computed(() => !!context.value?.appointment.replacementBatchKeys?.length || !!context.value?.appointment.replacementAppointmentNos?.length)
const hasCurrentDestinations = computed(() => replaced.value || context.value?.currentBatches.some(batch => batch.batchKey !== context.value?.appointment.batchKey))
const currentDestinations = computed(() => {
if (!context.value) return []
const sourceIdentity = (item: FbaAppointmentBatchContext['appointment']['items'][number]) => JSON.stringify([item.customerId, item.sourceKey, item.destinationKey])
const originalSources = new Set(context.value.originalSourceRefs.map(allocationKey))
return context.value.currentBatches.map(appointment => ({ appointment, items: appointment.items.filter(item => item.destinationKey && originalSources.has(sourceIdentity(item)) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
})
function bookingStatus(status: string) { return ({ PENDING: '待预约', SUCCESS: '已确认预约', FAILED: '未约成', CANCELLED: '已取消,分配保留' } as Record<string, string>)[status] || '预约状态待核实' }
async function openAppointment(number: string) { if (await canLeave()) emit('appointment', number) }
const canSubmit = computed(() => canWrite.value && ready.value && !loading.value && !error.value && !!context.value?.allowedActions.includes(mode.value === 'split' ? 'SPLIT' : 'MERGE') && (mode.value !== 'merge' || !!other.value?.allowedActions.includes('MERGE')))
async function load(preserve = false) {
loading.value = true; ready.value = false; error.value = null
try {
context.value = await getAppointmentBatch(activeKey.value)
if (other.value?.appointment.batchKey) other.value = await getAppointmentBatch(other.value.appointment.batchKey)
if (!preserve || initializedKey !== activeKey.value) {
Object.assign(form, { firstDate: context.value.appointment.plannedDate || '', nextDate: '', reason: '', remark: '', moved: Object.fromEntries(context.value.sources.map(source => [allocationKey(source), 0])) })
baseline.value = JSON.stringify(form)
initializedKey = activeKey.value
}
ready.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '批次资料读取失败') }
finally { loading.value = false }
}
async function searchCandidates(page = 1) {
const request = ++candidateRequest; candidatePage.value = page; candidates.value = null; candidateError.value = null; candidateLoading.value = true
try { const rows = await getWorkItems({ view: 'ALL', types: ['DELIVERY'], keyword: keyword.value.trim() || undefined, page, size: 8 }); if (request === candidateRequest) candidates.value = rows }
catch (cause) { if (request === candidateRequest) candidateError.value = getApiErrorDetails(cause, '可合并工作事项读取失败') }
finally { if (request === candidateRequest) candidateLoading.value = false }
}
async function chooseOther(key: string) {
if (!context.value || pending.value || saving.value) return
candidateLoading.value = true; candidateError.value = null
try {
const selected = await getAppointmentBatch(key)
if (!selected.allowedActions.includes('MERGE')) throw new Error(selected.blockReasons.join(';') || '该批次当前不允许合并')
mergeBatchSources([context.value, selected]); other.value = selected
} catch (cause) { candidateError.value = getApiErrorDetails(cause, '批次不能合并') }
finally { candidateLoading.value = false }
}
async function changeMode(next: 'split' | 'merge') {
if (!await canLeave()) return
mode.value = next; other.value = null; result.value = null; await load()
if (next === 'merge') await searchCandidates()
}
function sourceLabel(source: CargoVerificationSource) {
const item = context.value?.appointment.items.find(item => item.customerId === source.customerId && item.sourceKey === source.sourceKey)
return item?.inboundCode || '原票业务号未提供'
}
async function verify(source: CargoVerificationSource) { if (await canLeave()) { baseline.value = JSON.stringify(form); verification.value = source } }
async function verified() { verification.value = null; await load(); emit('updated') }
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; context.value = null; result.value = null; other.value = null; mode.value = 'split'; await load() }
async function save() {
if (!context.value || !canSubmit.value || saving.value) return
if (!pending.value) {
try {
if (!form.reason.trim()) { ElMessage.warning('请填写分批或合并原因'); return }
const contexts = mode.value === 'merge' && other.value ? [context.value, other.value] : [context.value]
const allocations = mode.value === 'split' ? splitBatch(context.value, form.moved) : [mergeBatchSources(contexts).map(source => allocation(source, Number(source.packageCount)))]
pending.value = { originals: contexts.map(row => ({ appointmentNo: row.appointment.appointmentNo, version: row.appointment.version })), batches: allocations.map((rows, index) => ({ plannedDate: (index === 0 ? form.firstDate : form.nextDate) || null, remark: form.remark.trim() || null, allocations: rows })), reason: form.reason.trim(), idempotencyKey: crypto.randomUUID() }
} catch (cause) { saveError.value = getApiErrorDetails(cause, '请核对逐票分配数量'); return }
}
saving.value = true; saveError.value = null
try {
result.value = await repartitionAppointments(pending.value); pending.value = null; baseline.value = JSON.stringify(form)
ElMessage.success(mode.value === 'split' ? '已分为两个待安排批次' : '已合并为一个待安排批次'); emit('updated')
} catch (cause) { saveError.value = getApiErrorDetails(cause, '调整结果待确认,请重试本次提交'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
finally { saving.value = false }
}
onMounted(() => load())
</script>
<template>
<section class="work-form" aria-label="预约分批合并">
<CargoVerificationForm v-if="verification" :key="`${verification.customerId}:${verification.sourceKey}`" :source="verification" @saved="verified" />
<template v-else>
<h3>{{ context?.appointment.appointmentNo || '预约批次' }} · 分批与合并</h3>
<LoadErrorState v-if="error" title="批次资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对批次、来源和核实版本…</p>
<template v-if="result"><p>原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。</p><div v-for="appointment in result.appointments" :key="appointment.appointmentNo" class="batch-result"><strong>{{ appointment.appointmentNo }}</strong><span>{{ appointment.plannedDate || '日期未定' }} · 待安排</span><el-button @click="emit('appointment', appointment.appointmentNo)">登记安排</el-button><el-button v-if="appointment.batchKey" @click="openBatch(appointment.batchKey)">继续分批</el-button></div></template>
<template v-else-if="context">
<p>{{ context.appointment.fbaWarehouseCode }} · {{ context.appointment.items.length }} 条货物来源;客户与原来源逐票保留。</p>
<template v-if="hasCurrentDestinations">
<p v-if="replaced">该批次已被替代,原货物快照仅供追溯。{{ context.appointment.repartitionReason }}</p>
<p v-else>本批仍有保留货物,以下同时列出服务端返回的当前后送去向。</p>
<section aria-label="当前货物去向">
<h4>当前货物去向</h4><p>以下仅展示原批次客户、来源及目的仓对应的当前分配;合并批次可能还含其他货物,进入完整批次后可查看。</p>
<article v-for="destination in currentDestinations" :key="destination.appointment.batchKey" class="allocation-row">
<strong>{{ destination.appointment.appointmentNo }}</strong><p>{{ bookingStatus(destination.appointment.appointmentStatus) }} · {{ destination.appointment.plannedDate || '日期未定' }}</p>
<div v-for="item in destination.items" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · {{ item.fbaWarehouseCode }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div>
<el-button :disabled="loading || !!error || saving || !!pending" @click="openAppointment(destination.appointment.appointmentNo)">查看本批预约</el-button>
<el-button v-if="destination.appointment.batchKey" :disabled="loading || !!error || saving || !!pending" @click="openBatch(destination.appointment.batchKey)">查看完整批次</el-button>
</article>
<p v-if="!currentDestinations.length && !loading && !error">当前没有与原货物范围匹配的分配;原快照不代表当前待办。</p>
</section>
</template>
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
<form @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<input v-model="form.firstDate" type="date"></label><label v-if="mode === 'split'">第二批目标日期(可不填)<input v-model="form.nextDate" type="date"></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
</template>
</template>
</section>
</template>
<style scoped>.allocation-row, .batch-result, .batch-candidate { padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); overflow-wrap: anywhere; }.batch-modes, .batch-result, .batch-candidate { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }.batch-candidate > span { flex: 1; }</style>