Files
admin-web/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue
T
2026-08-06 13:56:04 +08:00

389 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera, Truck } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import {
completeDelivery,
completeLoading,
completePicking,
confirmingPreload,
confirmPreload,
getEvidence,
getPicking,
getPreload,
smartSplit,
startPicking,
upsertEvidence,
type Evidence,
type EvidenceType,
type PickingTask,
type PreloadItem,
type PreloadPlan,
} from '@/modules/unified-orders/workflows/api'
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
const props = withDefaults(defineProps<{
tenantId: string
inboundId: string | number
mainStatus: UnifiedOrderMainStatus
inboundVersion?: number | null
pickingVersion?: number | null
deliveryTrackingNo?: string | null
}>(), {
inboundVersion: null,
pickingVersion: null,
deliveryTrackingNo: null,
})
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const preload = ref<PreloadPlan | null>(null)
const preloadItems = ref<PreloadItem[]>([])
const picking = ref<PickingTask | null>(null)
const evidences = ref<Evidence[]>([])
const deliveryRemark = ref('')
const loadingRemark = ref('')
const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
const error = ref('')
const notice = ref('')
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
{ type: 'PHOTO_STEP_1', label: '装柜照片 1' },
{ type: 'PHOTO_STEP_2', label: '装柜照片 2' },
{ type: 'PHOTO_STEP_3', label: '装柜照片 3' },
{ type: 'PHOTO_STEP_4', label: '装柜照片 4' },
{ type: 'SEAL', label: '封签' },
]
const isSplit = computed(() => props.mainStatus === 'WAIT_SPLIT')
const isLoading = computed(() => props.mainStatus === 'LOADING')
const isLoaded = computed(() => props.mainStatus === 'LOADED')
const preloadVersion = computed(() => preload.value?.version ?? 0)
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
const inboundExpectedVersion = computed(() => props.inboundVersion)
const canSplit = computed(() => access.can('firstleg:inbound:split'))
const canPreload = computed(() => access.can('firstleg:inbound:preload'))
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
const canPicking = computed(() => access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
const canEvidence = computed(() => access.can('platform:firstleg:inbound:loading:edit'))
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => access.can('platform:firstleg:inbound:loading:complete'))
const canDeliveryComplete = computed(() => access.can('platform:firstleg:inbound:delivery:complete'))
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
}
function addItem() {
preloadItems.value.push(itemTemplate())
}
function removeItem(index: number) {
preloadItems.value.splice(index, 1)
}
function evidence(type: EvidenceType) {
return evidences.value.find((item) => item.evidenceType === type)
}
function idempotentMessage(action: string) {
return busy.value === action ? '正在提交,请勿重复点击' : ''
}
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
busy.value = action
error.value = ''
notice.value = ''
try {
const result = await operation()
notice.value = message
emit('updated')
return result
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '操作失败,请重试'
return null
} finally {
busy.value = ''
}
}
async function loadPreload() {
if (!canPreloadView.value || !props.inboundId) return
loading.value = true
error.value = ''
try {
preload.value = await getPreload(props.tenantId, props.inboundId)
preloadItems.value = preload.value.items?.map((item) => ({ ...item })) || []
} catch (cause) {
preload.value = null
preloadItems.value = []
const missing = cause instanceof ApiResponseError
&& (cause.status === 404 || cause.code === 'A0404' || cause.code === 'NOT_FOUND')
error.value = missing ? '' : cause instanceof Error ? cause.message : '预装清单加载失败,请重试'
} finally {
loading.value = false
}
}
async function loadLoading() {
loading.value = true
error.value = ''
const messages: string[] = []
if (canPickingView.value) {
try {
picking.value = await getPicking(props.tenantId, props.inboundId)
} catch (cause) {
picking.value = null
messages.push(cause instanceof Error ? cause.message : '拣货任务加载失败')
}
}
if (canEvidenceView.value) {
try {
evidences.value = (await getEvidence(props.tenantId, props.inboundId)).evidences || []
} catch (cause) {
evidences.value = []
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
}
}
error.value = messages.join(';')
loading.value = false
}
async function refresh() {
if (isSplit.value) await loadPreload()
if (isLoading.value) await loadLoading()
}
async function runSmartSplit() {
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
items: preloadItems.value,
remark: splitRemark.value || undefined,
}), '智能分柜已提交')
if (result) {
preload.value = result.preloadPlan
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
}
}
async function startPreloadConfirmation() {
const result = await run('preload-confirming', () => confirmingPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已进入确认中')
if (result?.preloadPlan) preload.value = result.preloadPlan
}
async function finishPreloadConfirmation() {
const result = await run('preload-confirm', () => confirmPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已确认,订单进入装柜中')
if (result?.preloadPlan) preload.value = result.preloadPlan
}
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
if (pickingExpectedVersion.value === null) {
error.value = '拣货版本缺失,请刷新订单详情后重试'
return null
}
return {
expectedVersion: pickingExpectedVersion.value,
expectedCount: picking.value?.expectedCount ?? 0,
pickedCount: picking.value?.pickedCount ?? 0,
remark: loadingRemark.value || undefined,
}
}
async function startPickingTask() {
const request = pickingRequest()
if (!request) return
const result = await run('picking-start', () => startPicking(props.tenantId, props.inboundId, request), '拣货任务已开始')
if (result) picking.value = result
}
async function completePickingTask() {
const request = pickingRequest()
if (!request) return
const result = await run('picking-complete', () => completePicking(props.tenantId, props.inboundId, request), '拣货已完成')
if (result) picking.value = result
}
async function saveEvidence(type: EvidenceType) {
const current = evidence(type)
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
expectedVersion: current?.version ?? 0,
evidenceType: type,
fileId: current?.fileId || undefined,
fileName: current?.fileName || undefined,
sealNo: current?.sealNo || undefined,
remark: current?.remark || undefined,
}), `${type === 'SEAL' ? '封签' : '证据'}已保存`)
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
async function finishLoading() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
error.value = '入库单版本缺失,刷新订单详情后才能确认装柜完成'
return
}
const result = await run('loading-complete', () => completeLoading(props.tenantId, props.inboundId, {
expectedVersion,
remark: loadingRemark.value || undefined,
}), '装柜完成已提交')
if (result) await refresh()
}
async function finishDelivery() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
error.value = '入库单版本缺失,刷新订单详情后才能确认派送完成'
return
}
await run('delivery-complete', () => completeDelivery(props.tenantId, props.inboundId, {
expectedVersion,
remark: deliveryRemark.value || undefined,
}), '派送完成已提交')
}
function updateEvidenceField(type: EvidenceType, field: 'fileName' | 'sealNo' | 'remark', value: string) {
const current = evidence(type)
if (current) {
current[field] = value || null
return
}
evidences.value.push({
id: `draft-${type}`,
inboundId: props.inboundId,
tenantId: props.tenantId,
evidenceType: type,
fileId: null,
fileName: field === 'fileName' ? value : null,
sealNo: field === 'sealNo' ? value : null,
remark: field === 'remark' ? value : null,
version: 0,
createdAt: null,
updatedAt: null,
})
}
watch(() => props.mainStatus, () => { void refresh() })
onMounted(() => { void refresh() })
</script>
<template>
<section class="workflow-panel" aria-label="订单后半程作业">
<div class="workflow-heading">
<div>
<h2>后半程作业</h2>
<p>订单按待分柜、装柜中、装柜完成和派送完成推进;资料可修改,客户确认不阻断。</p>
</div>
<el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" />刷新</el-button>
</div>
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
<template #title>{{ error }}</template>
<el-button link type="danger" @click="refresh">重试</el-button>
</el-alert>
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</el-alert>
<template v-if="isSplit">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
<el-empty v-if="!preload && !loading" description="暂无预装清单,可直接生成分柜方案" />
<div class="preload-items" role="group" aria-label="预装清单明细">
<div v-for="(item, index) in preloadItems" :key="index" class="preload-row">
<el-input v-model="item.containerCode" aria-label="柜号" placeholder="柜号" />
<el-input v-model="item.cargoReference" aria-label="货物引用" placeholder="货物引用" />
<el-input-number v-model="item.packageCount" aria-label="件数" :min="1" controls-position="right" />
<el-input-number v-model="item.volumeCbm" aria-label="体积" :min="0" :precision="3" controls-position="right" />
<el-button text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
</div>
<el-button plain @click="addItem">新增预装项</el-button>
<el-input v-model="splitRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
</div>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />智能分柜/保存调整</el-button>
<el-button :disabled="!canPreload || !preload || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
</div>
</el-card>
</template>
<template v-if="isLoading">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>拣货与装柜</span><el-tag type="warning">装柜中</el-tag></div></template>
<el-descriptions v-if="picking" :column="2" border>
<el-descriptions-item label="拣货状态">{{ picking.status || '—' }}</el-descriptions-item>
<el-descriptions-item label="拣货版本">{{ picking.version }}</el-descriptions-item>
<el-descriptions-item label="应拣件数">{{ picking.expectedCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已拣件数">{{ picking.pickedCount ?? '—' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!loading" description="暂无拣货任务" />
<div class="workflow-actions">
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-start'" @click="startPickingTask"><Play :size="15" />开始拣货</el-button>
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-complete'" @click="completePickingTask"><Check :size="15" />完成拣货</el-button>
</div>
<el-input v-model="loadingRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
</el-card>
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>装柜证据与封签</span><el-tag type="info">四步照片</el-tag></div></template>
<div class="evidence-grid">
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
<strong><Camera :size="15" />{{ entry.label }}</strong>
<el-input v-if="entry.type === 'SEAL'" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="封签号" placeholder="封签号" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
<el-input v-else :model-value="evidence(entry.type)?.fileName || ''" aria-label="照片文件名" placeholder="文件上传完成后填写文件名" @update:model-value="updateEvidenceField(entry.type, 'fileName', $event)" />
<el-button plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存</el-button>
</div>
</div>
<p class="workflow-hint">当前只保存证据元数据;真实文件上传需接 File 服务契约,不会把本地路径伪装成已上传文件。</p>
<div class="workflow-actions">
<el-button type="primary" :disabled="!canLoadingComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
</template>
<el-card v-if="isLoaded" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>派送</span><el-tag type="success">装柜完成</el-tag></div></template>
<el-descriptions v-if="props.deliveryTrackingNo" :column="1" border>
<el-descriptions-item label="派送单号">{{ props.deliveryTrackingNo }}</el-descriptions-item>
</el-descriptions>
<el-input v-model="deliveryRemark" class="workflow-remark" type="textarea" :rows="3" aria-label="派送备注" placeholder="派送备注(可选)" />
<div class="workflow-actions">
<el-button type="primary" :disabled="!canDeliveryComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'delivery-complete'" @click="finishDelivery"><Truck :size="15" />确认派送完成</el-button>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
</section>
</template>
<style scoped>
.workflow-panel { display: grid; gap: 14px; }
.workflow-heading, .card-title, .workflow-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.workflow-heading { justify-content: space-between; }
.workflow-heading h2 { margin: 0; font-size: 17px; }
.workflow-heading p { margin: 4px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
.workflow-card { border-radius: 12px; }
.card-title { justify-content: space-between; }
.preload-items { display: grid; gap: 10px; }
.preload-row { display: grid; grid-template-columns: 1fr 1fr 120px 120px auto; gap: 8px; align-items: center; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.evidence-item { display: grid; grid-template-columns: minmax(100px, auto) 1fr auto; gap: 8px; align-items: center; }
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.workflow-alert { margin-bottom: 0; }
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
@media (max-width: 640px) {
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
.evidence-item { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}
</style>