303 lines
44 KiB
Vue
303 lines
44 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessageBox } from 'element-plus'
|
||
import { useAuthStore } from '@/core/auth/auth'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { createAppointmentForm, appointmentPayload, appointmentFormError, FbaAppointmentFields } from '@/modules/overseas-warehouse'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||
import ReceivingScope from './ReceivingScope.vue'
|
||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||
import { refreshReceivingCargo, type ReceivingCargoRefresh, getReceivingExecutionScope, type ReceivingExecutionScope, saveReceivingPlan, type ReceivingPlan, getReceivingContext, getReceivingGroup, proposeReceivingChange, resolveReceivingChange, bookReceiving, changeReceivingGroup, type ReceivingContext, type ReceivingGroup, type ReceivingMember, type ReceivingPending, type ReceivingResult, type ReceivingPropose, type ReceivingResolve, type ReceivingBook, type ReceivingGroupChange } from './receivingApi'
|
||
|
||
const props = defineProps<{ batchKey: string; canWrite: boolean; initialCustomerId?: string; owners?: Array<{ id: string; name: string }> }>()
|
||
const emit = defineEmits<{ updated: []; appointment: [number: string]; batch: [key: string]; external: [path: string] }>()
|
||
const auth = useAuthStore()
|
||
const owners = computed(() => [...new Map([...(props.owners || []), ...(auth.user ? [{ id: auth.user.id, name: auth.user.name }] : [])].filter(person => person.id).map(person => [person.id, person])).values()])
|
||
const access = useAccessPreviewStore(), context = ref<ReceivingContext | null>(null), group = ref<ReceivingGroup | null>(null)
|
||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), result = ref<ReceivingResult | null>(null)
|
||
const confirming = ref(false)
|
||
const mode = ref<'refresh' | 'plan' | 'overview' | 'customer' | 'resolve' | 'book' | 'reschedule' | 'cancel'>('overview')
|
||
const customer = ref(''), change = ref<ReceivingPending | null>(null), groupKey = ref<string | null>(null)
|
||
const adjustment = reactive({ retained: {} as Record<string, number | string>, laterDate: '', ownerId: '', followup: '', reason: '' })
|
||
const evidence = ref(''), separate = ref(false), confirmedScope = ref(false)
|
||
const bookingForms = ref<Array<ReturnType<typeof createAppointmentForm>>>([]), bookingEvidence = ref<string[]>([])
|
||
const bookingMemberKeys = ref<string[]>([]), selectedMemberKeys = ref<string[]>([])
|
||
const selectedExecution = ref<ReceivingExecutionScope | null>(null), scopeLoading = ref(false), scopeError = ref<ApiErrorDetails | null>(null)
|
||
const cargoContexts = ref<CargoVerificationContext[]>([]), cargoLoading = ref(false), cargoError = ref<ApiErrorDetails | null>(null)
|
||
let cargoGeneration = 0
|
||
let scopeGeneration = 0
|
||
let initializedGroup: string | null = null
|
||
const planForm = reactive(createAppointmentForm())
|
||
const groupForm = reactive(createAppointmentForm())
|
||
const state = () => JSON.stringify({ selectedMemberKeys: selectedMemberKeys.value, planForm, adjustment, evidence: evidence.value, separate: separate.value, bookingForms: bookingForms.value, bookingEvidence: bookingEvidence.value, groupForm })
|
||
const baseline = ref(state())
|
||
type Command = { kind: 'refresh'; request: ReceivingCargoRefresh } | { kind: 'plan'; request: ReceivingPlan } | { kind: 'propose'; request: ReceivingPropose } | { kind: 'resolve'; key: string; request: ReceivingResolve } | { kind: 'book'; request: ReceivingBook } | { kind: 'group'; key: string; request: ReceivingGroupChange }
|
||
const pending = ref<Command | null>(null)
|
||
const canLeave = useWorkDraft(computed(() => state() !== baseline.value), computed(() => saving.value || confirming.value || !!pending.value))
|
||
const readable = computed(() => access.can('platform:appointment:list'))
|
||
const edit = computed(() => props.canWrite && access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||
const remove = computed(() => props.canWrite && access.can('platform:appointment:delete'))
|
||
const formWritable = computed(() => mode.value === 'cancel' ? remove.value : ['customer', 'resolve'].includes(mode.value) ? edit.value && remove.value : edit.value)
|
||
const locked = computed(() => loading.value || saving.value || confirming.value || !!error.value || !!pending.value)
|
||
const sourceKey = (source: { sourceKey: string; destinationKey: string }) => JSON.stringify([source.sourceKey, source.destinationKey])
|
||
const customerItems = computed(() => context.value?.batch.items.filter(item => item.customerId === customer.value) || [])
|
||
const customers = computed(() => [...new Set(context.value?.batch.items.map(item => item.customerId) || [])])
|
||
function customerName(id: string) { return context.value?.agreements.flatMap(row => row.members).find(member => member.customerId === id)?.customerName || context.value?.pendingChanges.flatMap(row => row.originalMembers).find(member => member.customerId === id)?.customerName || null }
|
||
const eligibleCustomers = computed(() => customers.value.filter(id => context.value?.executionScope.members.some(row => row.member.batchKey === context.value?.batch.batchKey && row.member.customerId === id && row.canRebook && !row.completed)))
|
||
const uncoveredCustomers = computed(() => eligibleCustomers.value.filter(id => !context.value!.agreements.some(a => a.status === 'ACTIVE' && a.members.some(m => m.batchKey === props.batchKey && m.customerId === id)) && !context.value!.pendingChanges.some(c => c.status === 'WAITING' && c.intent.batchKey === props.batchKey && c.intent.customerId === id)))
|
||
const refreshReadable = computed(() => readable.value && access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||
const refreshEligible = (id: string) => refreshReadable.value && !!context.value?.allowedActions.includes('REFRESH_CARGO') && uncoveredCustomers.value.includes(id)
|
||
const planEligible = (id: string) => !!context.value?.allowedActions.includes('PLAN_RECEIVING') && uncoveredCustomers.value.includes(id)
|
||
const customerPlan = (id: string) => context.value?.plans.find(plan => plan.batchKey === props.batchKey && plan.customerId === id)
|
||
const planTimezone = computed(() => { const saved = customerPlan(customer.value); return saved ? saved.timezone : context.value?.batch.timezone })
|
||
const cargoIncomplete = computed(() => !!context.value?.batch.items.filter(item => eligibleCustomers.value.includes(item.customerId)).some(item => item.packageCount == null || !item.destinationKey || !item.sourceVersion))
|
||
const bookMembers = computed<ReceivingMember[]>(() => {
|
||
if (group.value) return group.value.eligibleMembers
|
||
const batch = context.value?.batch
|
||
if (!batch?.batchKey || cargoIncomplete.value) return []
|
||
return eligibleCustomers.value.filter(id => !context.value!.agreements.some(a => a.status === 'ACTIVE' && a.members.some(m => m.batchKey === batch.batchKey && m.customerId === id)) && !context.value!.pendingChanges.some(c => c.status === 'WAITING' && c.intent.customerId === id)).map(id => ({ batchKey: batch.batchKey!, appointmentNo: batch.appointmentNo, batchVersion: batch.version, customerId: id, customerName: customerName(id), sources: batch.items.filter(item => item.customerId === id).map(item => ({ sourceKey: item.sourceKey, inboundCode: item.inboundCode || null, destinationKey: item.destinationKey!, packageCount: item.packageCount!, sourceVersion: item.sourceVersion!, verificationVersion: item.cargoVerificationVersion ?? null })) }))
|
||
})
|
||
const cargoProblems = computed(() => {
|
||
const problems: string[] = []
|
||
if (!customerItems.value.length || customerItems.value.length > 100 || cargoContexts.value.length !== customerItems.value.length) return ['本客户来源范围未完整读取']
|
||
if (new Set(customerItems.value.map(item => JSON.stringify([item.sourceKey, item.destinationKey]))).size !== customerItems.value.length) problems.push('本客户存在重复来源,请核对原批次')
|
||
customerItems.value.forEach((item, index) => {
|
||
const row = cargoContexts.value[index]!, source = row.source, record = row.verification
|
||
if (source.sourceKey !== item.sourceKey || source.customerId !== customer.value || source.destinationKey !== item.destinationKey || source.originWarehouseId !== context.value?.batch.originWarehouseId || record && record.destinationKey !== item.destinationKey) problems.push('货物身份、起运仓或目的地与本批不一致')
|
||
if (row.sourceChanged || record && record.sourceVersion !== source.sourceVersion) problems.push('原货源已变化,请先重新核实资料')
|
||
if (!source.sourceVersion || record && (!Number.isSafeInteger(record.version) || record.version < 1)) problems.push('最新货物资料版本不完整')
|
||
if (item.packageCount == null || item.packageCount <= 0 || row.effectivePackages == null || !Number.isSafeInteger(row.effectivePackages) || row.effectivePackages <= 0 || row.availablePackages == null || row.availablePackages < 0 || row.unknownAllocationCount !== 0 || !Number.isSafeInteger(row.allocatedPackages) || row.allocatedPackages < 0) problems.push('本批箱数、最新核实量或分配量待核实')
|
||
if (row.effectivePackages != null && (row.allocatedPackages > row.effectivePackages || (item.packageCount ?? 0) > row.effectivePackages)) problems.push('已分配量或本批箱数超过最新核实量')
|
||
problems.push(...row.blockReasons, ...source.blockReasons)
|
||
})
|
||
return [...new Set(problems)]
|
||
})
|
||
const memberKey = (member: Pick<ReceivingMember, 'batchKey' | 'customerId'>) => JSON.stringify([member.batchKey, member.customerId])
|
||
const selectedBookMembers = computed(() => bookMembers.value.filter(member => selectedMemberKeys.value.includes(memberKey(member))))
|
||
const bookingRows = computed(() => !selectedBookMembers.value.length ? [] : separate.value ? selectedBookMembers.value.map(member => ({ member, index: bookMembers.value.findIndex(row => memberKey(row) === memberKey(member)) })) : [{ member: null, index: 0 }])
|
||
const selectedExecutionAllowed = computed(() => !!selectedExecution.value?.version && selectedBookMembers.value.every(member => selectedExecution.value!.members.some(row => memberKey(row.member) === memberKey(member) && row.canRebook && !row.completed)))
|
||
const bookingScopeChanged = computed(() => JSON.stringify(bookingMemberKeys.value) !== JSON.stringify(bookMembers.value.map(memberKey)))
|
||
const currentTrips = computed(() => [...new Set((group.value?.transport || context.value?.transport)?.vehicles.map(vehicle => vehicle.tripNo).filter((number): number is string => !!number) || [])])
|
||
const laterBatch = computed(() => result.value?.batches.find(batch => result.value?.steps.some(step => step.step === 'ALLOCATE_CARGO' && step.status === 'SUCCESS' && step.targetKey === batch.batchKey)))
|
||
const canSubmit = computed(() => {
|
||
if (loading.value || error.value || saving.value || confirming.value || result.value || !context.value) return false
|
||
if (pending.value) return pending.value.kind === 'group' && pending.value.request.action === 'CANCEL' ? remove.value : pending.value.kind === 'propose' || pending.value.kind === 'resolve' ? edit.value && remove.value : edit.value
|
||
if (!confirmedScope.value) return false
|
||
if (mode.value === 'refresh') return edit.value && refreshEligible(customer.value) && !cargoLoading.value && !cargoError.value && !cargoProblems.value.length
|
||
if (mode.value === 'plan') return edit.value && planEligible(customer.value)
|
||
if (mode.value === 'customer') return edit.value && remove.value && context.value.allowedActions.includes('PROPOSE_CUSTOMER_CHANGE')
|
||
if (mode.value === 'resolve') return edit.value && remove.value && change.value?.status === 'WAITING'
|
||
if (mode.value === 'cancel') return remove.value && group.value?.agreement.status === 'ACTIVE' && !group.value.transport.blockReasons.length
|
||
if (mode.value === 'reschedule') return edit.value && group.value?.agreement.status === 'ACTIVE' && !group.value.transport.blockReasons.length
|
||
return mode.value === 'book' && edit.value && !!selectedBookMembers.value.length && !bookingScopeChanged.value && !scopeLoading.value && !scopeError.value && selectedExecutionAllowed.value && (group.value ? group.value.agreement.status === 'CANCELLED' : context.value.allowedActions.includes('BOOK_RECEIVING'))
|
||
})
|
||
async function load() {
|
||
if (!readable.value) return
|
||
loading.value = true; error.value = null
|
||
try {
|
||
const next = await getReceivingContext(props.batchKey)
|
||
const nextGroup = groupKey.value ? await getReceivingGroup(groupKey.value) : null
|
||
context.value = next; group.value = nextGroup
|
||
if (nextGroup && initializedGroup !== nextGroup.agreement.agreementKey) {
|
||
Object.assign(groupForm, createAppointmentForm(), Object.fromEntries(Object.entries(nextGroup.agreement.details).map(([key, value]) => [key, value ?? ''])))
|
||
if (mode.value === 'book') initializeBookings()
|
||
initializedGroup = nextGroup.agreement.agreementKey; baseline.value = state()
|
||
}
|
||
if (change.value) change.value = next.pendingChanges.find(row => row.changeKey === change.value?.changeKey) || null
|
||
if (mode.value === 'book' && !result.value) await refreshSelectedScope()
|
||
if (mode.value === 'refresh' && !result.value) await loadCargo()
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, '预约覆盖范围读取失败') }
|
||
finally { loading.value = false }
|
||
}
|
||
function resetForm() { ++cargoGeneration; cargoLoading.value = false; cargoContexts.value = []; cargoError.value = null; ++scopeGeneration; selectedExecution.value = null; scopeLoading.value = false; scopeError.value = null; evidence.value = ''; confirmedScope.value = false; result.value = null; saveError.value = null; separate.value = false }
|
||
async function overview() { if (!await canLeave()) return; mode.value = 'overview'; groupKey.value = null; group.value = null; change.value = null; resetForm(); baseline.value = state(); await load() }
|
||
async function adjust(id: string) {
|
||
if (locked.value || !await canLeave()) return
|
||
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'customer'
|
||
adjustment.retained = Object.fromEntries(customerItems.value.filter(item => item.destinationKey).map(item => [sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey! }), item.packageCount == null ? '' : item.packageCount]))
|
||
adjustment.laterDate = ''; adjustment.ownerId = owners.value[0]?.id || ''; adjustment.followup = ''; adjustment.reason = ''; baseline.value = state()
|
||
}
|
||
async function openCargoRefresh(id: string) {
|
||
if (locked.value || !refreshEligible(id) || !await canLeave()) return
|
||
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'refresh'; baseline.value = state(); await loadCargo()
|
||
}
|
||
async function loadCargo() {
|
||
const generation = ++cargoGeneration; confirmedScope.value = false; cargoContexts.value = []; cargoError.value = null; cargoLoading.value = false
|
||
if (!refreshReadable.value || pending.value) return
|
||
if (!customerItems.value.length || customerItems.value.length > 100 || customerItems.value.some(item => !item.destinationKey)) { cargoError.value = { message: '本客户来源或目的地不完整,不能采用资料' }; return }
|
||
cargoLoading.value = true
|
||
try {
|
||
const rows = await Promise.all(customerItems.value.map(item => getCargoVerification({ sourceKey: item.sourceKey, customerId: customer.value, destinationKey: item.destinationKey! })))
|
||
if (generation === cargoGeneration) cargoContexts.value = rows
|
||
} catch (cause) { if (generation === cargoGeneration) cargoError.value = getApiErrorDetails(cause, '最新货物核实资料读取失败') }
|
||
finally { if (generation === cargoGeneration) cargoLoading.value = false }
|
||
}
|
||
async function openPlan(id: string) {
|
||
if (locked.value || !planEligible(id) || !await canLeave()) return
|
||
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'plan'
|
||
const saved = customerPlan(id)
|
||
const header = context.value!.batch
|
||
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : createAppointmentForm()
|
||
Object.assign(planForm, createAppointmentForm(), saved ? Object.fromEntries(Object.entries(saved.details).map(([key, value]) => [key, value ?? ''])) : { ...inherited, confirmationNo: '', appointmentAt: '', appointmentEndAt: '', startOffset: '', endOffset: '' })
|
||
baseline.value = state()
|
||
}
|
||
async function resume(pendingChange: ReceivingPending) { if (locked.value || !await canLeave()) return; resetForm(); change.value = pendingChange; mode.value = 'resolve'; baseline.value = state() }
|
||
function initializeBookings() { bookingMemberKeys.value = bookMembers.value.map(memberKey); selectedMemberKeys.value = [...bookingMemberKeys.value]; bookingForms.value = bookMembers.value.map(() => ({ ...createAppointmentForm(), appointmentStatus: 'SUCCESS' })); bookingEvidence.value = bookMembers.value.map(() => ''); confirmedScope.value = false; baseline.value = state() }
|
||
async function resetBookingScope() { if (await canLeave()) { initializeBookings(); await refreshSelectedScope() } }
|
||
async function openGroup(key: string, next: 'book' | 'reschedule' | 'cancel') {
|
||
if (locked.value || !await canLeave()) return
|
||
resetForm(); groupKey.value = key; group.value = null; initializedGroup = null; mode.value = next; await load()
|
||
baseline.value = state()
|
||
}
|
||
async function newBooking() { if (locked.value || !await canLeave()) return; resetForm(); groupKey.value = null; group.value = null; mode.value = 'book'; initializeBookings(); await refreshSelectedScope() }
|
||
async function refreshSelectedScope() {
|
||
const generation = ++scopeGeneration
|
||
confirmedScope.value = false; selectedExecution.value = null; scopeError.value = null; scopeLoading.value = false
|
||
if (mode.value !== 'book' || !readable.value || !selectedBookMembers.value.length || bookingScopeChanged.value) return
|
||
const members = selectedBookMembers.value.map(({ batchKey, customerId }) => ({ batchKey, customerId }))
|
||
scopeLoading.value = true
|
||
try {
|
||
const scope = await getReceivingExecutionScope(members)
|
||
if (generation === scopeGeneration) selectedExecution.value = scope
|
||
} catch (cause) { if (generation === scopeGeneration) scopeError.value = getApiErrorDetails(cause, '所选客户执行资格读取失败') }
|
||
finally { if (generation === scopeGeneration) scopeLoading.value = false }
|
||
}
|
||
onBeforeUnmount(() => { ++scopeGeneration; ++cargoGeneration })
|
||
function validation(message: string): never { throw new Error(message) }
|
||
function confirmedDetails(form: ReturnType<typeof createAppointmentForm>) { const invalid = appointmentFormError(form); if (invalid) validation(invalid); if (form.appointmentStatus !== 'SUCCESS') validation('此处登记仓库正式确认;尚未确认请使用本客户的“保存目标 / 未约成”入口'); return appointmentPayload(form) }
|
||
function buildCommand(decision: 'CONFIRM' | 'WITHDRAW'): Command {
|
||
const current = context.value!, idempotencyKey = crypto.randomUUID()
|
||
if (mode.value === 'refresh') {
|
||
return { kind: 'refresh', request: { idempotencyKey, member: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value }, executionVersion: current.executionScope.version, sources: cargoContexts.value.map(row => ({ sourceKey: row.source.sourceKey, destinationKey: row.source.destinationKey, expectedSourceVersion: row.source.sourceVersion!, expectedVerificationVersion: row.verification?.version ?? null })) } }
|
||
}
|
||
if (mode.value === 'plan') {
|
||
if (!['PENDING', 'FAILED'].includes(planForm.appointmentStatus)) validation('客户计划只能保存等待确认或未约成')
|
||
if (planForm.confirmationNo || planForm.appointmentAt || planForm.appointmentEndAt) validation('客户计划不能写入正式确认')
|
||
if ((planForm.plannedDate || planForm.plannedAt || planForm.plannedEndAt) && !planTimezone.value) validation('目的仓时区待核实,请先补齐;日期未定时可保存联系结果')
|
||
if (planForm.plannedEndAt && !planForm.plannedAt) validation('填写结束时段前请填写计划开始时段')
|
||
if (planForm.startOffset && !planForm.plannedAt || planForm.endOffset && !planForm.plannedEndAt) validation('仅填写对应计划时段时才可填写时差;仅日期或未定日期请清空时差')
|
||
for (const offset of [planForm.startOffset, planForm.endOffset]) if (offset.trim() && !/^[+-](?:0\d|1[0-7]):[0-5]\d$|^[+-]18:00$/.test(offset.trim())) validation('计划时差请按 +08:00 或 -07:00 格式填写')
|
||
const invalid = appointmentFormError(planForm); if (invalid) validation(invalid)
|
||
return { kind: 'plan', request: { idempotencyKey, member: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value }, details: appointmentPayload(planForm), executionVersion: current.executionScope.version } }
|
||
}
|
||
if (mode.value === 'customer') {
|
||
if (!owners.value.some(person => person.id === adjustment.ownerId) || !adjustment.reason.trim()) validation('请选择有效负责人并填写调整原因')
|
||
const quantities = customerItems.value.map(item => {
|
||
if (!item.destinationKey || !item.sourceVersion || item.packageCount == null) validation('本票数量或来源版本待核实,请先核实货物')
|
||
const raw = adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey! })]
|
||
if (raw === '' || raw == null) validation('请填写每票本次先送箱数,全部移出可填 0')
|
||
const retainedPackages = Number(raw)
|
||
if (!Number.isSafeInteger(retainedPackages) || retainedPackages < 0 || retainedPackages > item.packageCount!) validation('先送箱数须为 0 至本票当前箱数之间的整数')
|
||
return { sourceKey: item.sourceKey, destinationKey: item.destinationKey!, retainedPackages, expectedSourceVersion: item.sourceVersion!, expectedVerificationVersion: item.cargoVerificationVersion ?? null }
|
||
})
|
||
if (quantities.every((q, index) => q.retainedPackages === customerItems.value[index]!.packageCount)) validation('本次先送量未改变,无需提交调整')
|
||
return { kind: 'propose', request: { idempotencyKey, intent: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value, quantities, laterDate: adjustment.laterDate || null, ownerId: adjustment.ownerId, nextFollowupDate: adjustment.followup || null, reason: adjustment.reason.trim(), transportVersion: current.transport.version } } }
|
||
}
|
||
if (mode.value === 'resolve') {
|
||
if (!change.value) validation('待确认记录已变化,请刷新后核对')
|
||
if (decision === 'CONFIRM' && !evidence.value.trim()) validation('请填写仓库同意本次保留量和移出量的回复依据')
|
||
return { kind: 'resolve', key: change.value!.changeKey, request: { idempotencyKey, version: change.value!.version, decision, warehouseEvidence: evidence.value.trim() || null, transportVersion: current.transport.version } }
|
||
}
|
||
if (mode.value === 'book') {
|
||
const selected = selectedBookMembers.value
|
||
const groups = separate.value ? selected.map(member => [member]) : [selected]
|
||
return { kind: 'book', request: { idempotencyKey, cancelledAgreementKey: group.value?.agreement.agreementKey || null, cancelledAgreementVersion: group.value?.agreement.version ?? null, executionVersion: selectedExecution.value!.version, bookings: groups.map((members, index) => {
|
||
const formIndex = bookingRows.value[index]!.index
|
||
const note = bookingEvidence.value[formIndex]?.trim(); if (!note) validation('请逐份填写仓库确认覆盖客户和货量的依据')
|
||
return { members: members.map(({ batchKey, batchVersion, customerId }) => ({ batchKey, batchVersion, customerId })), details: confirmedDetails(bookingForms.value[formIndex]!), warehouseEvidence: note! }
|
||
}) } }
|
||
}
|
||
if (!group.value || !evidence.value.trim()) validation('请核对共同预约范围并填写仓库回复依据')
|
||
return { kind: 'group', key: group.value!.agreement.agreementKey, request: { idempotencyKey, version: group.value!.agreement.version, action: mode.value === 'cancel' ? 'CANCEL' : 'RESCHEDULE', details: mode.value === 'cancel' ? null : confirmedDetails(groupForm), warehouseEvidence: evidence.value.trim(), transportVersion: group.value!.transport.version } }
|
||
}
|
||
async function save(decision: 'CONFIRM' | 'WITHDRAW' = 'CONFIRM') {
|
||
if (!canSubmit.value) return
|
||
if (!pending.value) {
|
||
try {
|
||
const command = buildCommand(decision)
|
||
if (command.kind === 'group' && command.request.action === 'CANCEL') {
|
||
confirming.value = true
|
||
try { await ElMessageBox.confirm('确认取消当前展示的整份预约?货物需求与批次保留,车辆需重新核对。', '取消整份预约', { customClass: 'admin-density-confirm', confirmButtonText: '确认取消', cancelButtonText: '返回核对' }) } catch { return } finally { confirming.value = false }
|
||
}
|
||
pending.value = command
|
||
} catch (cause) { saveError.value = { message: cause instanceof Error ? cause.message : '请核对本次范围' }; return }
|
||
}
|
||
saving.value = true; saveError.value = null
|
||
try {
|
||
const command = pending.value
|
||
result.value = command.kind === 'refresh' ? await refreshReceivingCargo(command.request) : command.kind === 'plan' ? await saveReceivingPlan(command.request) : command.kind === 'propose' ? await proposeReceivingChange(command.request) : command.kind === 'resolve' ? await resolveReceivingChange(command.key, command.request) : command.kind === 'book' ? await bookReceiving(command.request) : await changeReceivingGroup(command.key, command.request)
|
||
pending.value = null; baseline.value = state(); confirmedScope.value = false; emit('updated'); await load()
|
||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '结果待确认,请重试本次提交'); if (rejectedCommand(cause)) { pending.value = null; confirmedScope.value = false; await load() } }
|
||
finally { saving.value = false }
|
||
}
|
||
async function openBatch(key: string) { if (await canLeave()) emit('batch', key) }
|
||
onMounted(async () => { await load(); if (props.initialCustomerId && customers.value.includes(props.initialCustomerId)) await adjust(props.initialCustomerId) })
|
||
</script>
|
||
<template>
|
||
<section class="work-form" aria-label="客户调整与共同预约">
|
||
<h3>{{ context?.batch.appointmentNo || '预约批次' }} · 客户调整与共同预约</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="context">
|
||
<el-button v-if="mode !== 'overview' || result" :disabled="locked" @click="overview">返回范围概览</el-button>
|
||
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||
<section v-if="result" aria-label="本次处理结果">
|
||
<h4>{{ result.status === 'WAITING' ? '已保存待确认,原安排尚未改变' : result.status === 'UNCHANGED' ? '安排没有变化' : result.status === 'WITHDRAWN' ? '已撤回变化,原安排保留' : '本次处理已保存' }}</h4>
|
||
<p v-for="(step, index) in result.steps" :key="index">{{ step.message }}</p>
|
||
<p v-if="result.nextActions.includes('REVIEW_TRANSPORT')">下一步:核对当前收货时段及车辆安排;本次保存不代表可以发车。</p>
|
||
<el-button v-if="result.nextActions.includes('BOOK_LATER_BATCH') && laterBatch" :type="result.nextActions[0] === 'BOOK_LATER_BATCH' ? 'primary' : 'default'" :disabled="locked" @click="emit('appointment', laterBatch.appointmentNo)">继续给后送批次约仓</el-button>
|
||
<template v-if="result.nextActions.includes('REVIEW_TRANSPORT')"><el-button v-for="(number, index) in currentTrips" :key="number" :type="index === 0 && result.nextActions[0] === 'REVIEW_TRANSPORT' ? 'primary' : 'default'" :disabled="locked || !access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(number)}`)">核对本车发车安排 · {{ number }}</el-button></template>
|
||
<el-button v-if="result.status === 'WAITING' && context.pendingChanges.find(row => row.changeKey === result?.changeKey)" type="primary" :disabled="locked" @click="resume(context.pendingChanges.find(row => row.changeKey === result?.changeKey)!)">继续登记仓库回复</el-button>
|
||
<template v-if="mode === 'refresh'"><p>本客户资料已处理,可继续保存本客户计划或登记正式预约。</p><el-button :disabled="locked || !planEligible(customer)" @click="openPlan(customer)">继续本客户目标 / 未约成</el-button><el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">核对未确认客户并登记正式预约</el-button></template><p v-if="mode === 'plan'">本客户计划已处理。可返回范围概览核对客户计划,或在本客户事项继续记录回复与跟进。</p><div v-for="batch in (['plan', 'refresh'].includes(mode) ? [] : result.batches).filter(row => row.items.some(item => item.packageCount == null || item.packageCount > 0))" :key="batch.batchKey"><p>{{ batch.appointmentNo }} · {{ batch.plannedDate || '日期未定' }}</p><el-button v-if="batch.batchKey" :disabled="locked" @click="openBatch(batch.batchKey)">查看本批当前去向</el-button><el-button :disabled="locked" @click="emit('appointment', batch.appointmentNo)">继续本批预约</el-button></div>
|
||
<el-button v-for="(agreement, index) in result.agreements.filter(row => row.status === 'CANCELLED')" :key="agreement.agreementKey" :type="index === 0 && result.nextActions[0] === 'REBOOK_GROUP' ? 'primary' : 'default'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'book')">重新预约这组货物</el-button>
|
||
</section>
|
||
<template v-else-if="mode === 'overview'">
|
||
<h4>各客户计划与货物调整</h4><p>先保存待确认变化,收到仓库明确回复后再生效;本次先送量允许为 0。</p>
|
||
<div v-for="(id, index) in customers" :key="id"><b>{{ customerName(id) || '—' }} · 客户范围 {{ index + 1 }}</b><p v-for="item in context.batch.items.filter(row => row.customerId === id)" :key="item.id">{{ item.inboundCode || '原票业务号未提供' }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p v-if="customerPlan(id)">本客户计划:{{ customerPlan(id)!.details.plannedDate || '日期未定' }} · {{ customerPlan(id)!.details.appointmentStatus === 'FAILED' ? '未约成' : '等待确认' }} · {{ customerPlan(id)!.details.appointmentRemark || '—' }}</p><el-button :disabled="locked || !edit || !refreshEligible(id)" @click="openCargoRefresh(id)">核对并采用本客户最新货物资料</el-button><el-button :disabled="locked || !edit || !planEligible(id)" @click="openPlan(id)">保存目标 / 未约成</el-button><el-button :disabled="locked || !edit || !remove || !context.allowedActions.includes('PROPOSE_CUSTOMER_CHANGE')" @click="adjust(id)">调整本客户先送 / 后送</el-button></div>
|
||
<h4>待仓库回复</h4><div v-for="pendingChange in context.pendingChanges.filter(row => row.status === 'WAITING')" :key="pendingChange.changeKey"><p>{{ pendingChange.intent.reason }} · 负责人 {{ pendingChange.followup.ownerName || '—' }} · 跟进 {{ pendingChange.followup.nextFollowupDate || '待安排' }}</p><el-button :disabled="locked" @click="resume(pendingChange)">继续本次变化</el-button></div>
|
||
<h4>整份预约范围</h4><div v-for="agreement in context.agreements" :key="agreement.agreementKey"><p>{{ agreement.details.confirmationNo || '约号未提供' }} · {{ agreement.status === 'ACTIVE' ? '有效' : agreement.status === 'CANCELLED' ? '已取消' : '历史' }} · {{ agreement.details.appointmentAt || '时段未定' }} → {{ agreement.details.appointmentEndAt || '结束未定' }} · {{ agreement.timezone || '时区待核实' }}</p><ReceivingScope :members="agreement.members" /><el-button v-if="agreement.status === 'ACTIVE'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'reschedule')">核对整份改期范围</el-button><el-button v-if="agreement.status === 'ACTIVE'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'cancel')">核对整份取消范围</el-button><el-button v-if="agreement.status === 'CANCELLED'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'book')">重新预约这组货物</el-button></div>
|
||
<ReceivingScope :members="[]" :transport="context.transport" />
|
||
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
|
||
</template>
|
||
<form v-else @submit.prevent="save()">
|
||
<fieldset :disabled="locked || !formWritable">
|
||
<template v-if="mode === 'refresh'">
|
||
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
|
||
<p v-if="cargoLoading">正在读取本客户各票最新核实资料…</p>
|
||
<LoadErrorState v-if="cargoError" title="最新货物核实资料读取失败" :message="cargoError.message" :loading="cargoLoading" @retry="loadCargo" />
|
||
<div v-for="(row, index) in cargoContexts" :key="index"><b>{{ row.source.inboundCode || row.source.bookingNo || '—' }}</b><p>本批 {{ customerItems[index]?.packageCount ?? '待核实' }} 箱 · 最新核实 {{ row.verification?.packageCount ?? '待核实' }} 箱 · 当前有效 {{ row.effectivePackages ?? '待核实' }} 箱 · 已分配 {{ row.allocatedPackages }} 箱</p><p>最新货件号:{{ row.verification?.shipmentReferences.join('、') || '—' }}</p></div>
|
||
<p v-for="problem in cargoProblems" :key="problem" role="status">{{ problem }}</p>
|
||
<el-button :disabled="locked || cargoLoading" @click="loadCargo">重新读取最新资料</el-button>
|
||
</template>
|
||
<template v-if="mode === 'plan'">
|
||
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划</h4>
|
||
<p v-for="item in customerItems" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount ?? '箱数待核实' }}</p>
|
||
<p>{{ planTimezone || '目的仓时区待核实' }} · 只保存本客户计划,其他客户的确认保持不变。</p>
|
||
<p v-if="!planEligible(customer)" role="status">本客户资格已变化,暂不能保存普通计划,请返回范围概览核对。</p>
|
||
<label>目标送仓日期<input v-model="planForm.plannedDate" type="date" aria-label="客户计划日期"></label>
|
||
<label>计划开始时段<input v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime-local" step="1" aria-label="客户计划开始"></label>
|
||
<label>计划结束时段<input v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime-local" step="1" aria-label="客户计划结束"></label>
|
||
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
|
||
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
|
||
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" /></label>
|
||
<p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p>
|
||
</template>
|
||
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<input v-model="adjustment.laterDate" type="date"></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<input v-model="adjustment.followup" type="date"></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
|
||
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" /></label></template>
|
||
<template v-if="group"><h4>{{ mode === 'book' ? '整组重约当前范围' : '整份预约当前范围' }}</h4><ReceivingScope :members="mode === 'book' ? selectedBookMembers : group.agreement.members" :transport="group.transport" /><div v-for="(excluded, index) in group.excludedMembers" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除范围 {{ index + 1 }} · {{ excluded.member.appointmentNo }}:{{ excluded.reason }}</div></template>
|
||
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" /></label></template></template>
|
||
<template v-if="mode === 'reschedule' || mode === 'cancel'"><FbaAppointmentFields v-if="mode === 'reschedule'" :model="groupForm" :timezone="group?.agreement.timezone" :disabled="locked || !edit" /><p v-else>整份取消会清除活动确认及失效目标,保留历史、批次数量和车辆关系;后续仍须重新约仓并核对车辆。</p><label>仓库回复依据<textarea v-model="evidence" maxlength="1000" aria-label="整份回复依据" /></label></template>
|
||
<label><input v-model="confirmedScope" type="checkbox" :disabled="mode === 'book' && (scopeLoading || !!scopeError || !selectedExecutionAllowed) || mode === 'refresh' && (cargoLoading || !!cargoError || !!cargoProblems.length)" aria-label="确认当前范围">已核对上述客户货物、当前预约与车辆范围</label>
|
||
</fieldset>
|
||
<el-button type="primary" native-type="submit" :disabled="!canSubmit" :loading="saving">{{ pending ? '重试本次提交' : mode === 'refresh' ? '确认采用本客户最新资料' : mode === 'plan' ? '保存本客户计划' : mode === 'customer' ? '保存待仓库确认' : mode === 'resolve' ? '确认回复并应用本次调整' : mode === 'cancel' ? '确认整份取消' : mode === 'reschedule' ? '保存整份改期' : '保存本次正式预约' }}</el-button>
|
||
<el-button v-if="mode === 'resolve' && !pending" :disabled="!canSubmit" @click="save('WITHDRAW')">撤回变化并保留原安排</el-button>
|
||
</form>
|
||
<LoadErrorState v-if="saveError" title="本次提交尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||
</template>
|
||
</section>
|
||
</template>
|