Files
admin-web/src/modules/overseas-master-data/components/TripPickupPanel.vue
T
2026-10-19 10:23:38 +08:00

133 lines
11 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { getTrip } from '../api/trips'
import { getPickupTasks, commandPickupTrip, commandPickupTask, uploadPickupEvidence, downloadPickupEvidence, pickupStatusLabels,
type PickupTask, type PickupCommand, type PickupConfirmation, type PickupEvidence } from '../api/pickups'
import { overseasMasterDataPermissions as permission } from '../permissions'
import type { TripDetail } from '../tripTypes'
const props = defineProps<{ detail: TripDetail }>(), emit = defineEmits<{ saved: [detail: TripDetail]; locked: [value: boolean] }>()
const access = useAccessPreviewStore(), rows = ref<PickupTask[]>([]), loading = ref(false), busy = ref(false), error = ref('')
const selected = ref<PickupTask | null>(null), open = ref(false), actual = ref(1), remark = ref(''), evidence = ref<PickupEvidence[]>([])
const needsTripRefresh = ref(false)
const pending = ref<{ action: 'depart' | 'complete' | 'picked' | 'arrive'; row: PickupTask | null; body: PickupCommand | PickupConfirmation } | null>(null)
// 命令结果未知时阻止父页面刷新和改派,保留原请求用于幂等重试。
watch(() => busy.value || !!pending.value, value => emit('locked', value), { flush: 'sync' })
let sequence = 0
const canComplete = computed(() => rows.value.length > 0 && rows.value.every(row => row.status === 'ARRIVED'))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 175, fixed: 'left', slotName: 'actions' },
{ key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
{ key: 'status', label: '取货进度', slotName: 'status', width: 110 }, { key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 270 },
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 145 },
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'actualPackages', label: '实提箱数', slotName: 'actual', width: 100 },
{ key: 'evidence', label: '取货凭证', slotName: 'evidence', minWidth: 180 }, { key: 'remark', prop: 'remark', label: '取货备注', minWidth: 180 },
]
/** 车次变化时丢弃旧请求;同车版本变化保留未确定结果的命令。 */
async function load() {
const current = ++sequence; loading.value = true; error.value = ''
try {
if (needsTripRefresh.value) {
const detail = await getTrip(props.detail.trip.tripNo)
if (current !== sequence) return
emit('saved', detail); needsTripRefresh.value = false
}
const value = await getPickupTasks(props.detail.trip.tripNo); if (current === sequence) rows.value = value
}
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货任务加载失败').message }
finally { if (current === sequence) loading.value = false }
}
/** 逐票录入实提,默认箱数仅供编辑,需操作员确认。 */
function edit(row: PickupTask) {
if (pending.value) return
selected.value = row; actual.value = row.packageCount; remark.value = ''; evidence.value = []; open.value = true
}
/** 凭证上传与确认分开,重试确认不重新上传文件。 */
async function upload(event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]
if (!file || !selected.value || busy.value) return
busy.value = true; error.value = ''; const current = sequence
try { const value = await uploadPickupEvidence(props.detail.trip.tripNo, selected.value, file); if (current === sequence) evidence.value.push(value) }
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货凭证上传失败').message }
finally { if (current === sequence) busy.value = false; input.value = '' }
}
/** 失败结果未知时固定原请求,成功后只刷新车次版本和成员。 */
async function execute() {
if (!pending.value || busy.value) return
const request = pending.value, current = sequence, number = props.detail.trip.tripNo
busy.value = true; error.value = ''
try {
if (request.action === 'depart' || request.action === 'complete') {
const detail = await commandPickupTrip(number, request.action, request.body as PickupCommand)
if (current === sequence) { emit('saved', detail); pending.value = null }
} else if (request.row) {
const tasks = await commandPickupTask(number, request.row, request.action, request.body)
if (current !== sequence) return
// 业务命令已经成功,后续刷新失败只重试读取,不能再次生成执行命令。
rows.value = tasks; pending.value = null; open.value = false; needsTripRefresh.value = true
const detail = await getTrip(number); if (current === sequence) { emit('saved', detail); needsTripRefresh.value = false }
}
} catch (cause) {
if (current === sequence) { error.value = getApiErrorDetails(cause, '取货操作未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
} finally { if (current === sequence) busy.value = false }
}
/** 发车、送仓和结案提交前说明本次事实。 */
async function command(action: 'depart' | 'arrive' | 'complete', row: PickupTask | null = null) {
if (pending.value || busy.value || needsTripRefresh.value) return
const messages = { depart: '确认车辆和司机已出发执行本车取货任务?', arrive: '确认该单货物已送达选定海外仓?确认后仓库可开始 PDA 清点。', complete: '确认本车全部取货订单已送仓,结束本次取货运输?' }
try { await ElMessageBox.confirm(messages[action], '确认取货运输', { customClass: 'admin-density-confirm' }) } catch { return }
pending.value = { action, row, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), reason: messages[action] } }
await execute()
}
/** 照片和实提箱数为取货确认的必要信息。 */
async function confirmPickup() {
if (pending.value) { await execute(); return }
if (needsTripRefresh.value) { ElMessage.warning('请先刷新车次后继续'); return }
if (!selected.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length) { ElMessage.warning('请填写实提箱数并上传至少一份取货凭证'); return }
pending.value = { action: 'picked', row: selected.value, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: remark.value.trim() } }
await execute()
}
/** 只下载本票确认的文件,使用完即释放临时地址。 */
async function download(row: PickupTask, file: PickupEvidence) {
try { const blob = await downloadPickupEvidence(row, file.fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败').message }
}
/** 原命令结果未知时保持弹框与请求快照。 */
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先确认原取货操作的结果'); return }; done() }
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先重试并确认原取货操作'); return false }; return true })
watch(() => props.detail.trip.tripNo, () => { pending.value = null; open.value = false; rows.value = []; void load() }, { immediate: true })
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<section aria-label="本车仓储转运取货任务">
<div class="density-actions pickup-actions">
<el-button v-if="detail.trip.status === 'PLANNED' && access.can(permission.tripAssign)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !detail.trip.vehicleId || !detail.trip.driverId" @click="command('depart')">确认取货发车</el-button>
<el-button v-if="detail.trip.status === 'IN_TRANSIT' && access.can(permission.tripComplete)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !canComplete" @click="command('complete')">取货运输结案</el-button>
<el-button :disabled="busy || !!pending" @click="load">刷新取货任务</el-button>
<el-button v-if="pending && !open" type="primary" :loading="busy" @click="execute">重试原操作</el-button>
</div>
<LoadErrorState v-if="error && !open" title="取货操作未完成" :message="error" @retry="pending ? execute() : load()" />
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="transferNo" empty-text="暂无取货成员">
<template #actions="{ row }"><div class="density-actions"><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'ASSIGNED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="edit(row as PickupTask)">确认取货</el-button><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'PICKED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="command('arrive', row as PickupTask)">确认送仓</el-button></div></template>
<template #status="{ row }">{{ pickupStatusLabels[row.status] || row.status }}</template><template #actual="{ row }">{{ row.actualPackages ?? '待取货' }}</template>
<template #evidence="{ row }"><div v-for="file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)">{{ file.fileName }}</el-button></div></template>
</DataTable>
<AppDialog v-model="open" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
<p>{{ selected?.transferNo }} · {{ selected?.pickupAddress }}</p>
<el-form label-position="top" :disabled="busy || !!pending">
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" /></el-form-item>
<el-form-item label="取货凭证" required><div><input type="file" accept="image/png,image/jpeg,application/pdf" :disabled="busy || !!pending || evidence.length >= 10" @change="upload"><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></div></el-form-item>
<el-form-item label="取货备注"><el-input v-model="remark" type="textarea" maxlength="1000" /></el-form-item>
</el-form><el-alert v-if="error" :title="error" type="error" :closable="false" />
</AppDialog>
</section>
</template>
<style scoped>.pickup-actions{margin-bottom:16px}</style>