feat(appointment): complete customer receiving and trip continuation workflows

This commit is contained in:
2026-10-16 15:41:56 +08:00
parent 148085286d
commit 45f662ecf2
45 changed files with 2042 additions and 131 deletions
@@ -9,23 +9,33 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { deleteAppointment } from './api'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
const emit = defineEmits<{ saved: []; batch: [key: string] }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(form))
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
let initialized = false
type SaveRequest = { kind: 'update'; payload: { details: ReturnType<typeof appointmentPayload>; version: number; idempotencyKey: string } } | { kind: 'cancel'; version: number; reason: string }
const pending = ref<SaveRequest | null>(null)
useWorkDraft(computed(() => JSON.stringify(form) !== baseline.value || !!reason.value), computed(() => saving.value || !!pending.value))
const changed = computed(() => JSON.stringify(appointmentPayload(form)) !== baseline.value)
useWorkDraft(computed(() => changed.value || !!reason.value), computed(() => saving.value || !!pending.value))
const replaced = computed(() => !!detail.value?.replacementAppointmentNos?.length || !!detail.value?.replacementBatchKeys?.length)
const writable = computed(() => props.canWrite && !replaced.value && detail.value?.appointmentStatus !== 'CANCELLED' && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
const rebooking = computed(() => !props.cancel && detail.value?.appointmentStatus === 'CANCELLED' && !replaced.value)
const writable = computed(() => props.canWrite && !replaced.value && (!props.cancel || detail.value?.appointmentStatus !== 'CANCELLED') && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
async function load(preserve = false) {
loading.value = true; error.value = null
try { detail.value = await getFbaAppointment(props.appointmentNo); if (!preserve) { Object.assign(form, createAppointmentForm(detail.value)); baseline.value = JSON.stringify(form) } }
try {
detail.value = await getFbaAppointment(props.appointmentNo)
if (!preserve || !initialized) {
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
}
}
catch (cause) { error.value = getApiErrorDetails(cause, '预约详情读取失败') }
finally { loading.value = false }
}
async function save() {
if (!detail.value || !writable.value || loading.value || error.value || saving.value) return
if (!props.cancel && !pending.value && !changed.value && !rebooking.value) return
if (!pending.value) {
if (props.cancel) {
if (!reason.value.trim()) { ElMessage.warning('请填写取消原因'); return }
@@ -38,9 +48,9 @@ async function save() {
saving.value = true; saveError.value = null
try {
if (pending.value.kind === 'cancel') await deleteAppointment(props.appointmentNo, pending.value.version, pending.value.reason)
else await updateFbaAppointment(props.appointmentNo, pending.value.payload)
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(form)
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved'); await load()
else detail.value = await updateFbaAppointment(props.appointmentNo, pending.value.payload)
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(appointmentPayload(form))
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved', detail.value); await load()
} catch (cause) { saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
finally { saving.value = false }
}
@@ -48,18 +58,21 @@ onMounted(() => load())
</script>
<template>
<section class="work-form" aria-label="预约安排">
<h3>{{ cancel ? '取消本次预约' : '登记预约安排' }}</h3>
<h3>{{ cancel ? '取消本次预约' : rebooking ? '沿原批次重新预约' : '登记预约安排' }}</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="detail">
<p>{{ detail.fbaWarehouseCode }} · {{ detail.appointmentNo }}</p>
<p v-if="rebooking">原预约已取消,货物分配仍保留。重新预约将更新本批安排,保留取消历史;请重新填写目标日期或仓库确认,日期未定也可先保存待预约。</p>
<p v-if="replaced">原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}</p>
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" /></label></template>
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<p v-if="!writable">当前预约仅可查看。</p>
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : '保存预约安排' }}</el-button>
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error || (!cancel && !rebooking && !pending && !changed)" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : rebooking ? '保存本批重新预约' : '保存预约安排' }}</el-button>
</template>
</section>
</template>