133 lines
11 KiB
Vue
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>
|