fix(admin): complete container loading workflows

This commit is contained in:
2026-08-11 11:15:44 +08:00
parent 76f5a1debf
commit 9b994040b8
13 changed files with 456 additions and 135 deletions
@@ -6,6 +6,7 @@ import {
completeLoading,
confirmingPreload,
deleteSmartLoadingItem,
downloadEvidenceFile,
getContainerBooking,
getOutboundDispatch,
getPicking,
@@ -86,6 +87,13 @@ describe('unified order workflow API contract', () => {
)
})
it('downloads loading evidence through the tenant and inbound scoped file endpoint', async () => {
const blob = new Blob(['photo'], { type: 'image/jpeg' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(downloadEvidenceFile('2', '8', '88')).resolves.toBe(blob)
expect(http.get).toHaveBeenCalledWith('/platform/v1/files/loading-evidence/2/8/88', { responseType: 'blob' })
})
it('normalizes smart-loading long identifiers as strings', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ data: { id: '2086085512165634050', warehouseId: '2086085512165634051', capacityCbm: '68', maxWeightKg: '25000', version: 2 } })
@@ -538,6 +538,17 @@ export async function uploadEvidenceFile(
return unwrapApiResponse(response)
}
export function downloadEvidenceFile(
tenantId: string | number,
inboundId: string | number,
fileId: string | number,
) {
return http.get<Blob, Blob>(
`/platform/v1/files/loading-evidence/${encodeURIComponent(String(tenantId))}/${encodeURIComponent(String(inboundId))}/${encodeURIComponent(String(fileId))}`,
{ responseType: 'blob' },
)
}
export async function voidEvidence(
tenantId: string | number,
inboundId: string | number,
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
import {
getEvidence,
getOutboundDispatch,
getPreload,
smartSplit,
@@ -14,6 +15,7 @@ vi.mock('../api', async (importOriginal) => {
const original = await importOriginal<typeof import('../api')>()
return {
...original,
getEvidence: vi.fn(),
getOutboundDispatch: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
@@ -29,14 +31,14 @@ function preload(cargoReference: string, version = 3) {
}
}
function mountPanel(permissions: string[]) {
function mountPanel(permissions: string[], mainStatus: 'WAIT_SPLIT' | 'LOADING' | 'LOADED' = 'WAIT_SPLIT') {
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().permissions = permissions
return mount(UnifiedOrderWorkflowPanel, {
props: {
tenantId: '2086085512165634052', inboundId: '2086085512165634051', orderId: '2086085512165634053',
mainStatus: 'WAIT_SPLIT', inboundVersion: 4,
mainStatus, inboundVersion: 4,
},
global: { plugins: [pinia, ElementPlus] },
})
@@ -45,6 +47,7 @@ function mountPanel(permissions: string[]) {
describe('UnifiedOrderWorkflowPanel preload editor', () => {
beforeEach(() => {
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
})
@@ -94,4 +97,23 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
expect(wrapper.text()).toContain('REF-OLD')
expect(wrapper.text()).not.toContain('REF-SERVER')
})
it('keeps completed loading evidence visible and renders every active photo', async () => {
vi.mocked(getEvidence).mockResolvedValue({
inboundId: '2086085512165634051',
evidences: [
{ id: '1', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '11', fileName: 'empty-1.jpg', sealNo: null, remark: null, version: 0, status: 'ACTIVE', createdAt: null, updatedAt: null },
{ id: '2', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '12', fileName: 'empty-2.jpg', sealNo: null, remark: null, version: 0, status: 'ACTIVE', createdAt: null, updatedAt: null },
],
})
const wrapper = mountPanel(['platform:firstleg:inbound:loading:view'], 'LOADED')
await flushPromises()
expect(getEvidence).toHaveBeenCalledWith('2086085512165634052', '2086085512165634051')
expect(wrapper.text()).toContain('empty-1.jpg')
expect(wrapper.text()).toContain('empty-2.jpg')
expect(wrapper.text()).toContain('2 张')
expect(wrapper.text()).not.toContain('完成装柜')
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera, Plus, Pencil, Trash2 } from '@lucide/vue'
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -14,6 +14,7 @@ import {
completePicking,
confirmingPreload,
confirmPreload,
downloadEvidenceFile,
getEvidence,
getOutboundDispatch,
getPicking,
@@ -54,6 +55,7 @@ const picking = ref<PickingTask | null>(null)
const evidences = ref<Evidence[]>([])
const dispatch = ref<OutboundDispatchAggregate | null>(null)
const loadingRemark = ref('')
const sealNoDraft = ref('')
const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
@@ -87,9 +89,12 @@ const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
const canEvidence = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:loading:edit'))
const canEvidence = computed(() => !props.readOnly && isLoading.value && access.can('platform:firstleg:inbound:loading:edit'))
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => !props.readOnly && !isLoaded.value && access.can('platform:firstleg:inbound:loading:complete'))
const evidenceReady = computed(() => evidenceTypes.every(({ type }) => activeEvidences(type).some((item) => (
Boolean(item.fileId) && (type !== 'SEAL' || Boolean(item.sealNo?.trim()))
))))
const preloadColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
@@ -147,8 +152,8 @@ function formatVolume(value: number | null) {
return value === null || !Number.isFinite(Number(value)) ? '—' : Number(value).toFixed(3)
}
function evidence(type: EvidenceType) {
return evidences.value.find((item) => item.evidenceType === type && item.status === 'ACTIVE')
function activeEvidences(type: EvidenceType) {
return evidences.value.filter((item) => item.evidenceType === type && item.status === 'ACTIVE')
}
function idempotentMessage(action: string) {
@@ -216,7 +221,9 @@ async function loadLoading() {
picking.value = await getPicking(props.tenantId, props.inboundId)
} catch (cause) {
picking.value = null
messages.push(cause instanceof Error ? cause.message : '拣货任务加载失败')
const details = getApiErrorDetails(cause, '拣货任务加载失败')
messages.push(details.message)
errorDetails.value ||= details
}
}
if (canEvidenceView.value) {
@@ -224,7 +231,9 @@ async function loadLoading() {
evidences.value = (await getEvidence(props.tenantId, props.inboundId)).evidences || []
} catch (cause) {
evidences.value = []
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
const details = getApiErrorDetails(cause, '装柜证据加载失败')
messages.push(details.message)
errorDetails.value ||= details
}
}
error.value = messages.join(';')
@@ -242,7 +251,7 @@ async function loadDispatch() {
async function refresh() {
await loadDispatch()
if (isSplit.value) await loadPreload()
if (isLoading.value) await loadLoading()
if (isLoading.value || isLoaded.value) await loadLoading()
}
async function runSmartSplit() {
@@ -309,35 +318,29 @@ async function completePickingTask() {
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 sealNo = sealNoDraft.value.trim()
if (type === 'SEAL' && !sealNo) {
error.value = '上传带锁码照前必须填写锁码'
errorDetails.value = null
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,
expectedVersion: 0,
evidenceType: type,
fileId: uploaded.fileId,
sealNo: current?.sealNo || undefined,
remark: current?.remark || undefined,
sealNo: type === 'SEAL' ? sealNo : undefined,
}), `${type === 'SEAL' ? '带锁码照' : '证据'}已登记`)
if (result?.evidences) evidences.value = result.evidences.evidences || []
if (result?.evidences) {
evidences.value = result.evidences.evidences || []
if (type === 'SEAL') sealNoDraft.value = ''
}
}
async function markEvidenceVoided(item: Evidence) {
@@ -351,6 +354,25 @@ async function markEvidenceVoided(item: Evidence) {
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
async function previewEvidence(item: Evidence) {
if (!canEvidenceView.value || !item.fileId) return
busy.value = `evidence-preview-${item.id}`
error.value = ''
errorDetails.value = null
try {
const blob = await downloadEvidenceFile(props.tenantId, props.inboundId, item.fileId)
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (cause) {
const details = getApiErrorDetails(cause, '证据照片加载失败,请重试')
error.value = details.message
errorDetails.value = details
} finally {
busy.value = ''
}
}
async function finishLoading() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
@@ -364,28 +386,6 @@ async function finishLoading() {
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',
createdAt: null,
updatedAt: null,
})
}
watch(() => props.mainStatus, () => { void refresh() })
onMounted(() => { void refresh() })
</script>
@@ -404,7 +404,7 @@ onMounted(() => { void refresh() })
</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>
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前仅开放进度查询;五图取证和装柜完成门禁接通后再开放操作。</el-alert>
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前为只读查看,不能修改作业数据。</el-alert>
<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>
@@ -472,29 +472,37 @@ onMounted(() => { void refresh() })
<el-input v-model="loadingRemark" :disabled="props.readOnly" 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>
</template>
<el-card v-if="isLoading || isLoaded" v-loading="loading" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>装柜五类证据与锁码</span><el-tag :type="evidenceReady ? 'success' : 'info'">{{ evidenceReady ? '证据齐全' : '五类必传' }}</el-tag></div></template>
<el-alert v-if="!canEvidenceView" type="warning" :closable="false" show-icon>当前账号无装柜证据查询权限。</el-alert>
<div class="evidence-grid">
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
<strong><Camera :size="15" />{{ entry.label }}</strong>
<strong><Camera :size="15" />{{ entry.label }}<el-tag size="small" :type="activeEvidences(entry.type).length ? 'success' : 'danger'">{{ activeEvidences(entry.type).length }} 张</el-tag></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>
<el-input v-if="entry.type === 'SEAL' && canEvidence" v-model="sealNoDraft" maxlength="64" show-word-limit aria-label="下一张照片的锁码" placeholder="先填写锁码,再上传带锁码照" />
<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 v-if="canEvidenceView" class="evidence-files">
<div v-for="item in activeEvidences(entry.type)" :key="String(item.id)" class="evidence-file">
<span>{{ item.fileName || `证据 #${item.id}` }}<template v-if="item.sealNo"> · 锁码 {{ item.sealNo }}</template></span>
<div class="evidence-file__actions">
<el-button link type="primary" :disabled="Boolean(busy)" @click="previewEvidence(item)"><Eye :size="14" />查看</el-button>
<el-button v-if="canEvidence" link type="danger" :disabled="Boolean(busy)" @click="markEvidenceVoided(item)">作废</el-button>
</div>
</div>
<span v-if="activeEvidences(entry.type).length === 0" class="workflow-hint">尚未上传有效照片</span>
</div>
</div>
</div>
<p class="workflow-hint">五类证据各至少一张有效照片;锁码文本与带锁码照同时必填。错误照片只能作废并保留审计记录。</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>
<div v-if="isLoading" class="workflow-actions">
<el-button type="primary" :disabled="!canLoadingComplete || !evidenceReady || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
<span v-if="!evidenceReady" class="workflow-hint">五类有效照片及锁码齐全后可提交;其他门禁仍由服务端校验</span>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
</template>
</el-card>
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
@@ -538,10 +546,13 @@ onMounted(() => { void refresh() })
.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; }
.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
.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; }
.evidence-files { display: grid; gap: 4px; }
.evidence-file { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.evidence-file span { min-width: 0; overflow-wrap: anywhere; }
.evidence-file__actions { display: flex; flex: 0 0 auto; 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; }