Files
admin-web/src/modules/overseas-master-data/components/TripBookingPanel.vue
T

145 lines
16 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking } from '../api/tripBookings'
import { bookingStatusLabels, type TripBooking, type TripBookingCargo, type TripBookingCreate, type TripBookingCommand, type TripBookingResult, type FleetCustomerOption } from '../bookingTypes'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import type { FleetTrip } from '../tripTypes'
const props = defineProps<{ trip: FleetTrip }>()
const emit = defineEmits<{ changed: [version: number]; linked: [] }>()
const access = useAccessPreviewStore()
const records = ref<TripBooking[]>([]), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const fulfillmentOpen = ref(false), fulfillmentBooking = ref<TripBooking | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') { fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true }
function fulfilled(value: TripBookingResult) { accept(value); if (fulfillmentMode.value === 'bind') { emit('linked'); void load() } }
const readOnly = ref(false)
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
const pendingCreate = ref<TripBookingCreate | null>(null), pendingCommand = ref<{ bookingNo: string; action: 'confirm' | 'cancel'; body: TripBookingCommand } | null>(null)
const version = ref<number | null>(null), customerId = ref('')
const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: 1, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
const form = reactive<TripBookingCargo>(blank())
let generation = 0, searchSequence = 0
let searchTimer: ReturnType<typeof setTimeout> | undefined
const planned = computed(() => props.trip.status === 'PLANNED')
const quotedAmount = computed(() => form.unitPrice == null ? null : form.priceBasis === 'ORDER' ? form.unitPrice : form.palletSpaces == null ? null : Math.round(form.unitPrice * form.palletSpaces * 100) / 100)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 440, fixed: 'left' },
{ key: 'bookingNo', prop: 'bookingNo', label: '外接预订号', minWidth: 225 },
{ key: 'customerName', prop: 'customerName', label: '委托客户', minWidth: 170 },
{ key: 'destinationLabel', prop: 'destinationLabel', label: '卸货站', minWidth: 140 },
{ key: 'status', label: '状态', slotName: 'status', width: 130 },
{ key: 'cargo', label: '承运货量', slotName: 'cargo', minWidth: 225 },
{ key: 'amount', label: '约定运费', slotName: 'amount', minWidth: 160 },
{ key: 'documents', label: '来货 / 排车', slotName: 'documents', minWidth: 230 },
]
async function load() {
const current = ++generation; loading.value = true; error.value = null
try { const value = await listTripBookings(props.trip.tripNo); if (current === generation) { records.value = value.records; version.value = value.tripRecordVersion
if (pendingCommand.value) { const row = value.records.find(row => row.bookingNo === pendingCommand.value!.bookingNo); if (row && (row.status === 'CANCELLED' || pendingCommand.value.action === 'confirm' && row.status === 'CONFIRMED')) pendingCommand.value = null } } }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '外接预订加载失败') }
finally { if (current === generation) loading.value = false }
}
function searchCustomers(keyword = '') {
clearTimeout(searchTimer); const current = ++searchSequence
searchTimer = setTimeout(async () => {
customersLoading.value = true
try { const rows = await listFleetCustomers(keyword.trim() || undefined); if (current === searchSequence) customers.value = rows }
catch (cause) { if (current === searchSequence) error.value = getApiErrorDetails(cause, '客户目录加载失败') }
finally { if (current === searchSequence) customersLoading.value = false }
}, 250)
}
function edit(row?: TripBooking, viewOnly = false) {
readOnly.value = viewOnly
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
editing.value = row || null
if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo) }
else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()) }
open.value = true
}
function accept(value: TripBookingResult) {
version.value = value.tripRecordVersion
const index = records.value.findIndex(row => row.bookingNo === value.booking.bookingNo)
if (index < 0) records.value.push(value.booking); else records.value[index] = value.booking
emit('changed', value.tripRecordVersion)
}
function failure(cause: unknown, fallback: string) {
error.value = getApiErrorDetails(cause, fallback)
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pendingCreate.value = null; pendingCommand.value = null }
}
async function save() {
if (busy.value || readOnly.value || version.value == null) return
if (!customerId.value || !form.destinationKey || !form.packageCount) { ElMessage.warning('请选择客户、卸货站并填写箱数'); return }
const current = generation; busy.value = true; error.value = null
try {
let value: TripBookingResult
if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }) })
else {
pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), idempotencyKey: crypto.randomUUID() }
value = await createTripBooking(props.trip.tripNo, pendingCreate.value)
}
if (current === generation) { accept(value); pendingCreate.value = null; open.value = false; ElMessage.success('外接询价已保存') }
} catch (cause) { if (current === generation) failure(cause, '外接登记未完成') }
finally { busy.value = false }
}
async function command(row: TripBooking, action: 'confirm' | 'cancel') {
if (busy.value || version.value == null) return
if (pendingCommand.value && (pendingCommand.value.bookingNo !== row.bookingNo || pendingCommand.value.action !== action)) { ElMessage.warning('请先重试上一条操作或刷新核对'); return }
const current = generation
if (!pendingCommand.value) {
try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量预留本车容量?` : `取消 ${row.bookingNo},释放尚未执行的预订容量?已到仓货物继续保留在仓库。`, action === 'confirm' ? '确认外接承运' : '取消外接预订', { customClass: 'admin-density-confirm' }) }
catch { return }
if (current !== generation) return
pendingCommand.value = { bookingNo: row.bookingNo, action, body: { tripRecordVersion: version.value, recordVersion: row.recordVersion, idempotencyKey: crypto.randomUUID() } }
}
busy.value = true; error.value = null
try {
const value = await (action === 'confirm' ? confirmTripBooking : cancelTripBooking)(props.trip.tripNo, row.bookingNo, pendingCommand.value.body)
if (current === generation) { accept(value); pendingCommand.value = null; ElMessage.success('外接预订状态已更新') }
} catch (cause) { if (current === generation) failure(cause, '外接预订操作未完成') }
finally { busy.value = false }
}
watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true })
onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer) })
</script>
<template>
<div>
<p>询价不占用车辆空间;确认承运后预留容量。实收时核对运费,转派送时按确认报价登记应收,付款状态以财务为准。</p>
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="readOnly || busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}</template>
</DataTable>
<AppDialog v-model="open" class="admin-density-dialog" :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
<el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
<el-form-item label="委托客户" required><el-select v-model="customerId" filterable remote :remote-method="searchCustomers" :loading="customersLoading" :disabled="!!editing" placeholder="按客户名称、编号搜索" @focus="searchCustomers()"><el-option v-if="editing" :value="editing.customerId" :label="editing.customerName" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.destinationKey" placeholder="选择顺路卸货站"><el-option v-for="stop in trip.stops.slice(1).filter(row => row.destinationKey)" :key="stop.destinationKey!" :value="stop.destinationKey!" :label="stop.name" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargoName" placeholder="填写货物名称(选填)" /></el-form-item>
<el-form-item label="箱数" required><el-input-number v-model="form.packageCount" :min="1" :precision="0" placeholder="预计箱数" /></el-form-item>
<el-form-item label="预计标准托位"><el-input-number v-model="form.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="报价方式"><el-select v-model="form.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
<el-form-item :label="form.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.unitPrice" :min="0" :precision="2" placeholder="可先保存询价" /></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency" filterable allow-create placeholder="选择或输入币种"><el-option v-for="currency in ['USD','CAD','EUR','GBP','CNY']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.contactName" placeholder="填写联系人(选填)" /></el-form-item>
<el-form-item label="联系方式"><el-input v-model="form.contactPhone" placeholder="填写电话或其他联系方式(选填)" /></el-form-item>
<el-form-item label="备注" class="booking-wide"><el-input v-model="form.remark" type="textarea" placeholder="其他交货说明(选填)" /></el-form-item>
</el-form>
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
</AppDialog>
<TripBookingFulfillmentDialog v-model="fulfillmentOpen" :trip="trip" :booking="fulfillmentBooking" :mode="fulfillmentMode" @saved="fulfilled" />
</div>
</template>
<style scoped>.booking-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.booking-form :deep(.el-select),.booking-form :deep(.el-input-number){width:100%}.booking-wide{grid-column:1/-1}</style>