Files
admin-web/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue
T

438 lines
21 KiB
Vue
Raw Normal View History

2026-08-06 13:56:04 +08:00
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera } from '@lucide/vue'
2026-08-06 13:56:04 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import {
completeLoading,
completePicking,
confirmingPreload,
confirmPreload,
getEvidence,
getOutboundDispatch,
2026-08-06 13:56:04 +08:00
getPicking,
getPreload,
smartSplit,
startPicking,
upsertEvidence,
uploadEvidenceFile,
voidEvidence,
2026-08-06 13:56:04 +08:00
type Evidence,
type EvidenceType,
type PickingTask,
type PreloadItem,
type PreloadPlan,
type OutboundDispatchAggregate,
2026-08-06 13:56:04 +08:00
} from '@/modules/unified-orders/workflows/api'
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
const props = withDefaults(defineProps<{
tenantId: string
inboundId: string | number
orderId?: string | number | null
2026-08-06 13:56:04 +08:00
mainStatus: UnifiedOrderMainStatus
inboundVersion?: number | null
pickingVersion?: number | null
2026-08-10 09:12:06 +08:00
readOnly?: boolean
2026-08-06 13:56:04 +08:00
}>(), {
inboundVersion: null,
pickingVersion: null,
2026-08-10 09:12:06 +08:00
readOnly: false,
2026-08-06 13:56:04 +08:00
})
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 dispatch = ref<OutboundDispatchAggregate | null>(null)
2026-08-06 13:56:04 +08:00
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: '空仓照' },
{ type: 'PHOTO_STEP_2', label: '半仓照' },
{ type: 'PHOTO_STEP_3', label: '满仓照' },
{ type: 'PHOTO_STEP_4', label: '锁仓照' },
{ type: 'SEAL', label: '带锁码照' },
2026-08-06 13:56:04 +08:00
]
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)
2026-08-10 09:12:06 +08:00
const canSplit = computed(() => !props.readOnly && access.can('firstleg:inbound:split'))
const canPreload = computed(() => !props.readOnly && access.can('firstleg:inbound:preload'))
2026-08-06 13:56:04 +08:00
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
2026-08-10 09:12:06 +08:00
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
2026-08-06 13:56:04 +08:00
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
2026-08-10 09:12:06 +08:00
const canEvidence = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:loading:edit'))
2026-08-06 13:56:04 +08:00
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => !props.readOnly && !isLoaded.value && access.can('platform:firstleg:inbound:loading:complete'))
2026-08-06 13:56:04 +08:00
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 && item.status === 'ACTIVE')
2026-08-06 13:56:04 +08:00
}
function idempotentMessage(action: string) {
return busy.value === action ? '正在提交,请勿重复点击' : ''
}
function gateItemName(code: string, fallback: string) {
return code === 'LOADING_EVIDENCE_COMPLETE' ? '五类装柜证据与锁码完整' : fallback
}
2026-08-06 13:56:04 +08:00
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 : '装柜证据加载失败')
}
}
try {
dispatch.value = await getOutboundDispatch(props.tenantId, props.orderId || undefined, props.inboundId)
} catch {
dispatch.value = null
}
2026-08-06 13:56:04 +08:00
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 uploadAndSaveEvidence(type: EvidenceType, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !canEvidence.value) return
const uploaded = await run(`evidence-upload-${type}`, () => uploadEvidenceFile(props.tenantId, props.inboundId, type, file), '文件已上传,正在登记证据')
if (!uploaded) return
const current = evidence(type)
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
expectedVersion: current?.version ?? 0,
evidenceType: type,
fileId: uploaded.fileId,
sealNo: current?.sealNo || undefined,
remark: current?.remark || undefined,
}), `${type === 'SEAL' ? '带锁码照' : '证据'}已登记`)
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
async function markEvidenceVoided(item: Evidence) {
if (!canEvidence.value || !item.id || String(item.id).startsWith('draft-')) return
const reason = window.prompt('请输入照片作废原因')?.trim()
if (!reason) return
const result = await run(`evidence-void-${item.id}`, () => voidEvidence(props.tenantId, props.inboundId, item.id, {
expectedVersion: item.version,
reason,
}), '证据已作废')
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
2026-08-06 13:56:04 +08:00
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()
}
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,
status: 'ACTIVE',
2026-08-06 13:56:04 +08:00
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>
2026-08-10 09:12:06 +08:00
<p>查看订单的分柜、预装、拣货和装柜进度。</p>
2026-08-06 13:56:04 +08:00
</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>
2026-08-10 09:12:06 +08:00
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前仅开放进度查询;五图取证和装柜完成门禁接通后再开放操作。</el-alert>
2026-08-06 13:56:04 +08:00
<el-card v-if="dispatch" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>子流程与门禁摘要</span><el-tag :type="dispatch.gate?.passed ? 'success' : 'warning'">{{ dispatch.gate?.passed ? '已通过' : '待补齐' }}</el-tag></div></template>
<div class="gate-grid">
<div><span>订舱</span><strong>{{ dispatch.oceanBookingStatus || '暂无数据' }}</strong></div>
<div><span>拖车</span><strong>{{ dispatch.truckAppointmentStatus || '暂无数据' }}</strong></div>
<div><span>柜号</span><strong>{{ dispatch.containerNo || '暂无数据' }}</strong></div>
<div v-for="item in dispatch.gate?.items || []" :key="item.code"><span>{{ gateItemName(item.code, item.name) }}</span><el-tag size="small" :type="item.passed ? 'success' : 'danger'">{{ item.passed ? '已满足' : '待补齐' }}</el-tag></div>
</div>
<p class="workflow-hint">此处为订舱、拖车、PDA、报关、证据和船期的只读摘要;装柜完成仍以服务端 LOADED 门禁为准。</p>
</el-card>
2026-08-06 13:56:04 +08:00
<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">
2026-08-10 09:12:06 +08:00
<el-input v-model="item.containerCode" :disabled="props.readOnly" aria-label="柜号" placeholder="柜号" />
<el-input v-model="item.cargoReference" :disabled="props.readOnly" aria-label="货物引用" placeholder="货物引用" />
<el-input-number v-model="item.packageCount" :disabled="props.readOnly" aria-label="件数" :min="1" controls-position="right" />
<el-input-number v-model="item.volumeCbm" :disabled="props.readOnly" aria-label="体积" :min="0" :precision="3" controls-position="right" />
<el-button v-if="!props.readOnly" text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
2026-08-06 13:56:04 +08:00
</div>
2026-08-10 09:12:06 +08:00
<el-button v-if="!props.readOnly" plain @click="addItem">新增预装项</el-button>
<el-input v-model="splitRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
2026-08-06 13:56:04 +08:00
</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>
2026-08-10 09:12:06 +08:00
<el-input v-model="loadingRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
2026-08-06 13:56:04 +08:00
</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>
2026-08-06 13:56:04 +08:00
<div class="evidence-grid">
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
<strong><Camera :size="15" />{{ entry.label }}</strong>
<div class="evidence-control">
<el-input v-if="entry.type === 'SEAL'" :disabled="props.readOnly" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="锁码" placeholder="锁码(带锁码照必填)" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
<span v-if="evidence(entry.type)?.fileId" class="workflow-hint">{{ evidence(entry.type)?.fileName || '已上传文件' }}</span>
<label v-if="canEvidence" class="file-picker"><Camera :size="14" />上传照片<input type="file" accept="image/*" @change="uploadAndSaveEvidence(entry.type, $event)" /></label>
</div>
<div class="evidence-actions">
<el-button v-if="entry.type === 'SEAL'" plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存锁码</el-button>
<el-button v-for="item in evidences.filter((candidate) => candidate.evidenceType === entry.type && candidate.status === 'ACTIVE')" :key="String(item.id)" link type="danger" :disabled="!canEvidence || Boolean(busy)" @click="markEvidenceVoided(item)">作废</el-button>
</div>
2026-08-06 13:56:04 +08:00
</div>
</div>
<p class="workflow-hint">五类证据各至少一张有效照片;锁码文本与带锁码照同时必填。错误照片只能作废并保留审计记录。</p>
2026-08-06 13:56:04 +08:00
<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-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
</el-alert>
2026-08-06 13:56:04 +08:00
</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) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
2026-08-06 13:56:04 +08:00
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.evidence-control { display: flex; min-width: 0; align-items: center; gap: 8px; }
.evidence-actions { display: flex; align-items: center; gap: 4px; }
.gate-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.gate-grid > div { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 9px; border: 1px solid var(--cc-border); border-radius: 5px; }
.gate-grid span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.gate-grid strong { color: var(--cc-text, #1f2937); font-size: 12px; }
.file-picker { display: inline-flex; align-items: center; gap: 4px; color: var(--cc-primary); font-size: 12px; cursor: pointer; white-space: nowrap; }
.file-picker input { display: none; }
2026-08-06 13:56:04 +08:00
.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, .gate-grid { grid-template-columns: 1fr; }
2026-08-06 13:56:04 +08:00
.workflow-actions > .el-button { flex: 1 1 150px; }
}
</style>