feat: complete phase three admin loading workflow
This commit is contained in:
@@ -148,8 +148,8 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => {
|
it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => {
|
||||||
expect(source).toContain('UnifiedOrderWorkflowPanel')
|
expect(source).toContain('UnifiedOrderWorkflowPanel')
|
||||||
expect(source).toContain("{ key: 'workflow', label: '后半程作业'")
|
expect(source).toContain("{ key: 'workflow', label: '后半程作业'")
|
||||||
expect(source).toContain(':read-only="true"')
|
expect(source).toContain(':order-id="String(detail.summary.orderId)"')
|
||||||
expect(source).toContain(':show-delivery="false"')
|
expect(source).not.toContain(':show-delivery=')
|
||||||
expect(source).toContain("detail.value?.summary.inboundId")
|
expect(source).toContain("detail.value?.summary.inboundId")
|
||||||
expect(routeSource).not.toContain('unified-order-workflow')
|
expect(routeSource).not.toContain('unified-order-workflow')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -931,12 +931,10 @@ onMounted(() => {
|
|||||||
<UnifiedOrderWorkflowPanel
|
<UnifiedOrderWorkflowPanel
|
||||||
:tenant-id="String(detail.summary.tenantId)"
|
:tenant-id="String(detail.summary.tenantId)"
|
||||||
:inbound-id="String(detail.summary.inboundId)"
|
:inbound-id="String(detail.summary.inboundId)"
|
||||||
|
:order-id="String(detail.summary.orderId)"
|
||||||
:main-status="detail.summary.mainStatus"
|
:main-status="detail.summary.mainStatus"
|
||||||
:inbound-version="detail.summary.inboundVersion"
|
:inbound-version="detail.summary.inboundVersion"
|
||||||
:picking-version="detail.summary.loading?.pickingVersion"
|
:picking-version="detail.summary.loading?.pickingVersion"
|
||||||
:delivery-tracking-no="detail.summary.delivery?.trackingNo"
|
|
||||||
:read-only="true"
|
|
||||||
:show-delivery="false"
|
|
||||||
@updated="loadDetail(true)"
|
@updated="loadDetail(true)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
- `POST /split/smart`:智能分柜/保存预装清单
|
- `POST /split/smart`:智能分柜/保存预装清单
|
||||||
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
|
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
|
||||||
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
|
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
|
||||||
- `GET/POST /loading/evidence`:四步照片与封签证据元数据
|
- `POST /platform/v1/files/loading-evidence/{tenantId}/{inboundId}`:上传真实装柜证据文件
|
||||||
|
- `GET/POST /loading/evidence`:查询并登记五类装柜证据及锁码
|
||||||
|
- `POST /loading/evidence/{evidenceId}/void`:按原因作废错误照片并保留审计
|
||||||
- `POST /loading/complete`:完成装柜
|
- `POST /loading/complete`:完成装柜
|
||||||
- `POST /delivery/complete`:确认派送完成
|
|
||||||
|
|
||||||
所有写请求带 `Idempotency-Key` 和服务端要求的 `expectedVersion`。`loading/complete` 与 `delivery/complete` 使用入库单版本;拣货使用拣货任务版本。当前没有 File 服务上传接口,证据组件不会把本地路径伪装成已上传文件。
|
所有 Firstleg 写请求带 `Idempotency-Key` 和服务端要求的 `expectedVersion`。`loading/complete` 使用入库单版本;拣货使用拣货任务版本。国内端不提供在线冲正、回退或推进 `DELIVERED`。
|
||||||
|
|
||||||
权限码、状态流转和请求字段以 Firstleg Controller/DTO 为准;本模块不新增“进入分柜”操作,也不定义通用 `/actions/{ACTION}` 接口。
|
权限码、状态流转和请求字段以 Firstleg Controller/DTO 为准;本模块不新增“进入分柜”操作,也不定义通用 `/actions/{ACTION}` 接口。
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { http } from '@/shared/api/http'
|
import { http } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
completeDelivery,
|
|
||||||
completeLoading,
|
completeLoading,
|
||||||
confirmingPreload,
|
confirmingPreload,
|
||||||
getPicking,
|
getPicking,
|
||||||
getPreload,
|
getPreload,
|
||||||
smartSplit,
|
smartSplit,
|
||||||
|
uploadEvidenceFile,
|
||||||
|
voidEvidence,
|
||||||
} from './api'
|
} from './api'
|
||||||
|
|
||||||
vi.mock('@/shared/api/http', () => ({
|
vi.mock('@/shared/api/http', () => ({
|
||||||
@@ -45,15 +46,6 @@ describe('unified order workflow API contract', () => {
|
|||||||
expect(vi.mocked(http.post).mock.calls.some(([url]) => String(url).includes('/actions/'))).toBe(false)
|
expect(vi.mocked(http.post).mock.calls.some(([url]) => String(url).includes('/actions/'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the inbound version for delivery completion', async () => {
|
|
||||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'DELIVERED', evidences: null } })
|
|
||||||
await completeDelivery('2', '8', { expectedVersion: 9, remark: '已确认派送' })
|
|
||||||
expect(http.post).toHaveBeenCalledWith(
|
|
||||||
'/platform/v1/inbounds/8/delivery/complete', { expectedVersion: 9, remark: '已确认派送' },
|
|
||||||
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not swallow non-success responses', async () => {
|
it('does not swallow non-success responses', async () => {
|
||||||
vi.mocked(http.get).mockRejectedValue(new Error('无权限'))
|
vi.mocked(http.get).mockRejectedValue(new Error('无权限'))
|
||||||
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
|
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
|
||||||
@@ -67,4 +59,20 @@ describe('unified order workflow API contract', () => {
|
|||||||
expect.objectContaining({ params: { tenantId: '2' } }),
|
expect.objectContaining({ params: { tenantId: '2' } }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uploads a real evidence file and voids evidence through audited endpoints', async () => {
|
||||||
|
vi.mocked(http.post)
|
||||||
|
.mockResolvedValueOnce({ data: { fileId: '88', fileName: 'empty.jpg' } })
|
||||||
|
.mockResolvedValueOnce({ data: { mainStatus: 'LOADING', evidences: { inboundId: '8', evidences: [] } } })
|
||||||
|
await uploadEvidenceFile('2', '8', 'PHOTO_STEP_1', new File(['photo'], 'empty.jpg', { type: 'image/jpeg' }))
|
||||||
|
await voidEvidence('2', '8', '33', { expectedVersion: 2, reason: '照片错误' })
|
||||||
|
expect(http.post).toHaveBeenNthCalledWith(1,
|
||||||
|
'/platform/v1/files/loading-evidence/2/8', expect.any(FormData),
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||||
|
)
|
||||||
|
expect(http.post).toHaveBeenNthCalledWith(2,
|
||||||
|
'/platform/v1/inbounds/8/loading/evidence/33/void', { expectedVersion: 2, reason: '照片错误' },
|
||||||
|
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,25 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
|||||||
|
|
||||||
const inboundBasePath = '/platform/v1/inbounds'
|
const inboundBasePath = '/platform/v1/inbounds'
|
||||||
|
|
||||||
|
export interface OutboundGateItem { code: string; name: string; passed: boolean; actual?: string | null; requirement?: string | null }
|
||||||
|
export interface OutboundDispatchAggregate {
|
||||||
|
orderId: number | string | null
|
||||||
|
orderNo: string | null
|
||||||
|
inboundId: number | string | null
|
||||||
|
inboundCode: string | null
|
||||||
|
containerBookingId: number | string | null
|
||||||
|
containerNo: string | null
|
||||||
|
oceanBookingId: number | string | null
|
||||||
|
oceanBookingStatus: string | null
|
||||||
|
truckAppointmentId: number | string | null
|
||||||
|
truckAppointmentStatus: string | null
|
||||||
|
customsStatus?: string | null
|
||||||
|
sailingStatus?: string | null
|
||||||
|
gate: { passed: boolean; items: OutboundGateItem[] } | null
|
||||||
|
}
|
||||||
|
|
||||||
export type PreloadStatus = 'PENDING' | 'CONFIRMING' | 'CONFIRMED' | string
|
export type PreloadStatus = 'PENDING' | 'CONFIRMING' | 'CONFIRMED' | string
|
||||||
|
/** 五类装柜取证。旧接口仍使用 PHOTO_STEP_* / SEAL 编码,页面统一映射为业务名称。 */
|
||||||
export type EvidenceType = 'PHOTO_STEP_1' | 'PHOTO_STEP_2' | 'PHOTO_STEP_3' | 'PHOTO_STEP_4' | 'SEAL'
|
export type EvidenceType = 'PHOTO_STEP_1' | 'PHOTO_STEP_2' | 'PHOTO_STEP_3' | 'PHOTO_STEP_4' | 'SEAL'
|
||||||
|
|
||||||
export interface PreloadItem {
|
export interface PreloadItem {
|
||||||
@@ -50,8 +68,13 @@ export interface Evidence {
|
|||||||
sealNo: string | null
|
sealNo: string | null
|
||||||
remark: string | null
|
remark: string | null
|
||||||
version: number
|
version: number
|
||||||
|
status: 'ACTIVE' | 'VOIDED' | string
|
||||||
createdAt: string | null
|
createdAt: string | null
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
|
voidReason?: string | null
|
||||||
|
voidedBy?: number | string | null
|
||||||
|
voidedByName?: string | null
|
||||||
|
voidedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EvidenceList {
|
export interface EvidenceList {
|
||||||
@@ -90,15 +113,23 @@ export interface UpsertEvidenceRequest {
|
|||||||
remark?: string
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadEvidenceResult {
|
||||||
|
fileId: number | string
|
||||||
|
fileName: string | null
|
||||||
|
contentType?: string | null
|
||||||
|
sizeBytes?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoidEvidenceRequest {
|
||||||
|
expectedVersion: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CompleteLoadingRequest {
|
export interface CompleteLoadingRequest {
|
||||||
expectedVersion: number
|
expectedVersion: number
|
||||||
remark?: string
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompleteDeliveryRequest {
|
|
||||||
expectedVersion: number
|
|
||||||
remark?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function query(tenantId: string | number) {
|
function query(tenantId: string | number) {
|
||||||
return { tenantId: String(tenantId) }
|
return { tenantId: String(tenantId) }
|
||||||
@@ -119,6 +150,15 @@ export async function getPreload(tenantId: string | number, inboundId: string |
|
|||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOutboundDispatch(tenantId: string | number, orderId?: string | number, inboundId?: string | number) {
|
||||||
|
const response = await http.get<ApiEnvelope<OutboundDispatchAggregate[]>, ApiEnvelope<OutboundDispatchAggregate[]>>(
|
||||||
|
'/platform/v1/first-leg/outbound-dispatches',
|
||||||
|
{ params: { tenantId: String(tenantId), ...(orderId ? { orderId: String(orderId) } : {}), ...(inboundId ? { inboundId: String(inboundId) } : {}) } },
|
||||||
|
)
|
||||||
|
const result = unwrapApiResponse(response)
|
||||||
|
return result?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
export async function smartSplit(
|
export async function smartSplit(
|
||||||
tenantId: string | number,
|
tenantId: string | number,
|
||||||
inboundId: string | number,
|
inboundId: string | number,
|
||||||
@@ -204,6 +244,38 @@ export async function upsertEvidence(
|
|||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 真实 File 服务上传;返回的 fileId 才能提交证据,不接受本地文件名冒充。 */
|
||||||
|
export async function uploadEvidenceFile(
|
||||||
|
tenantId: string | number,
|
||||||
|
inboundId: string | number,
|
||||||
|
evidenceType: EvidenceType,
|
||||||
|
file: File,
|
||||||
|
) {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('evidenceType', evidenceType)
|
||||||
|
body.append('file', file)
|
||||||
|
const response = await http.post<ApiEnvelope<UploadEvidenceResult>, ApiEnvelope<UploadEvidenceResult>>(
|
||||||
|
`/platform/v1/files/loading-evidence/${tenantId}/${inboundId}`,
|
||||||
|
body,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function voidEvidence(
|
||||||
|
tenantId: string | number,
|
||||||
|
inboundId: string | number,
|
||||||
|
evidenceId: string | number,
|
||||||
|
request: VoidEvidenceRequest,
|
||||||
|
) {
|
||||||
|
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
|
||||||
|
`${inboundBasePath}/${inboundId}/loading/evidence/${evidenceId}/void`,
|
||||||
|
request,
|
||||||
|
writeOptions('evidence-void', inboundId, tenantId),
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeLoading(
|
export async function completeLoading(
|
||||||
tenantId: string | number,
|
tenantId: string | number,
|
||||||
inboundId: string | number,
|
inboundId: string | number,
|
||||||
@@ -215,15 +287,3 @@ export async function completeLoading(
|
|||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeDelivery(
|
|
||||||
tenantId: string | number,
|
|
||||||
inboundId: string | number,
|
|
||||||
request: CompleteDeliveryRequest,
|
|
||||||
) {
|
|
||||||
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
|
|
||||||
`${inboundBasePath}/${inboundId}/delivery/complete`, request,
|
|
||||||
writeOptions('delivery-complete', inboundId, tenantId),
|
|
||||||
)
|
|
||||||
return unwrapApiResponse(response)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { RefreshCw, Save, Check, Play, Camera, Truck } from '@lucide/vue'
|
import { RefreshCw, Save, Check, Play, Camera } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { ApiResponseError } from '@/shared/api/http'
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
completeDelivery,
|
|
||||||
completeLoading,
|
completeLoading,
|
||||||
completePicking,
|
completePicking,
|
||||||
confirmingPreload,
|
confirmingPreload,
|
||||||
confirmPreload,
|
confirmPreload,
|
||||||
getEvidence,
|
getEvidence,
|
||||||
|
getOutboundDispatch,
|
||||||
getPicking,
|
getPicking,
|
||||||
getPreload,
|
getPreload,
|
||||||
smartSplit,
|
smartSplit,
|
||||||
startPicking,
|
startPicking,
|
||||||
upsertEvidence,
|
upsertEvidence,
|
||||||
|
uploadEvidenceFile,
|
||||||
|
voidEvidence,
|
||||||
type Evidence,
|
type Evidence,
|
||||||
type EvidenceType,
|
type EvidenceType,
|
||||||
type PickingTask,
|
type PickingTask,
|
||||||
type PreloadItem,
|
type PreloadItem,
|
||||||
type PreloadPlan,
|
type PreloadPlan,
|
||||||
|
type OutboundDispatchAggregate,
|
||||||
} from '@/modules/unified-orders/workflows/api'
|
} from '@/modules/unified-orders/workflows/api'
|
||||||
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
|
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
tenantId: string
|
tenantId: string
|
||||||
inboundId: string | number
|
inboundId: string | number
|
||||||
|
orderId?: string | number | null
|
||||||
mainStatus: UnifiedOrderMainStatus
|
mainStatus: UnifiedOrderMainStatus
|
||||||
inboundVersion?: number | null
|
inboundVersion?: number | null
|
||||||
pickingVersion?: number | null
|
pickingVersion?: number | null
|
||||||
deliveryTrackingNo?: string | null
|
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
showDelivery?: boolean
|
|
||||||
}>(), {
|
}>(), {
|
||||||
inboundVersion: null,
|
inboundVersion: null,
|
||||||
pickingVersion: null,
|
pickingVersion: null,
|
||||||
deliveryTrackingNo: null,
|
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
showDelivery: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{ updated: [] }>()
|
const emit = defineEmits<{ updated: [] }>()
|
||||||
@@ -46,7 +46,7 @@ const preload = ref<PreloadPlan | null>(null)
|
|||||||
const preloadItems = ref<PreloadItem[]>([])
|
const preloadItems = ref<PreloadItem[]>([])
|
||||||
const picking = ref<PickingTask | null>(null)
|
const picking = ref<PickingTask | null>(null)
|
||||||
const evidences = ref<Evidence[]>([])
|
const evidences = ref<Evidence[]>([])
|
||||||
const deliveryRemark = ref('')
|
const dispatch = ref<OutboundDispatchAggregate | null>(null)
|
||||||
const loadingRemark = ref('')
|
const loadingRemark = ref('')
|
||||||
const splitRemark = ref('')
|
const splitRemark = ref('')
|
||||||
const busy = ref('')
|
const busy = ref('')
|
||||||
@@ -55,11 +55,11 @@ const error = ref('')
|
|||||||
const notice = ref('')
|
const notice = ref('')
|
||||||
|
|
||||||
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
|
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
|
||||||
{ type: 'PHOTO_STEP_1', label: '装柜照片 1' },
|
{ type: 'PHOTO_STEP_1', label: '空仓照' },
|
||||||
{ type: 'PHOTO_STEP_2', label: '装柜照片 2' },
|
{ type: 'PHOTO_STEP_2', label: '半仓照' },
|
||||||
{ type: 'PHOTO_STEP_3', label: '装柜照片 3' },
|
{ type: 'PHOTO_STEP_3', label: '满仓照' },
|
||||||
{ type: 'PHOTO_STEP_4', label: '装柜照片 4' },
|
{ type: 'PHOTO_STEP_4', label: '锁仓照' },
|
||||||
{ type: 'SEAL', label: '封签' },
|
{ type: 'SEAL', label: '带锁码照' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const isSplit = computed(() => props.mainStatus === 'WAIT_SPLIT')
|
const isSplit = computed(() => props.mainStatus === 'WAIT_SPLIT')
|
||||||
@@ -73,10 +73,10 @@ const canPreload = computed(() => !props.readOnly && access.can('firstleg:inboun
|
|||||||
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
|
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
|
||||||
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
|
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
|
||||||
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
|
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 && access.can('platform:firstleg:inbound:loading:edit'))
|
||||||
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
|
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
|
||||||
const canLoadingComplete = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:loading:complete'))
|
const canLoadingComplete = computed(() => !props.readOnly && !isLoaded.value && access.can('platform:firstleg:inbound:loading:complete'))
|
||||||
const canDeliveryComplete = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:delivery:complete'))
|
|
||||||
|
|
||||||
function itemTemplate(): PreloadItem {
|
function itemTemplate(): PreloadItem {
|
||||||
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
|
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
|
||||||
@@ -91,13 +91,17 @@ function removeItem(index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function evidence(type: EvidenceType) {
|
function evidence(type: EvidenceType) {
|
||||||
return evidences.value.find((item) => item.evidenceType === type)
|
return evidences.value.find((item) => item.evidenceType === type && item.status === 'ACTIVE')
|
||||||
}
|
}
|
||||||
|
|
||||||
function idempotentMessage(action: string) {
|
function idempotentMessage(action: string) {
|
||||||
return busy.value === action ? '正在提交,请勿重复点击' : ''
|
return busy.value === action ? '正在提交,请勿重复点击' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gateItemName(code: string, fallback: string) {
|
||||||
|
return code === 'LOADING_EVIDENCE_COMPLETE' ? '五类装柜证据与锁码完整' : fallback
|
||||||
|
}
|
||||||
|
|
||||||
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
|
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
|
||||||
busy.value = action
|
busy.value = action
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -153,6 +157,11 @@ async function loadLoading() {
|
|||||||
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
|
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
dispatch.value = await getOutboundDispatch(props.tenantId, props.orderId || undefined, props.inboundId)
|
||||||
|
} catch {
|
||||||
|
dispatch.value = null
|
||||||
|
}
|
||||||
error.value = messages.join(';')
|
error.value = messages.join(';')
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -228,6 +237,35 @@ async function saveEvidence(type: EvidenceType) {
|
|||||||
if (result?.evidences) evidences.value = result.evidences.evidences || []
|
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 || []
|
||||||
|
}
|
||||||
|
|
||||||
async function finishLoading() {
|
async function finishLoading() {
|
||||||
const expectedVersion = inboundExpectedVersion.value
|
const expectedVersion = inboundExpectedVersion.value
|
||||||
if (expectedVersion === null) {
|
if (expectedVersion === null) {
|
||||||
@@ -241,18 +279,6 @@ async function finishLoading() {
|
|||||||
if (result) await refresh()
|
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) {
|
function updateEvidenceField(type: EvidenceType, field: 'fileName' | 'sealNo' | 'remark', value: string) {
|
||||||
const current = evidence(type)
|
const current = evidence(type)
|
||||||
if (current) {
|
if (current) {
|
||||||
@@ -269,6 +295,7 @@ function updateEvidenceField(type: EvidenceType, field: 'fileName' | 'sealNo' |
|
|||||||
sealNo: field === 'sealNo' ? value : null,
|
sealNo: field === 'sealNo' ? value : null,
|
||||||
remark: field === 'remark' ? value : null,
|
remark: field === 'remark' ? value : null,
|
||||||
version: 0,
|
version: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
})
|
})
|
||||||
@@ -296,6 +323,17 @@ onMounted(() => { void refresh() })
|
|||||||
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<template v-if="isSplit">
|
<template v-if="isSplit">
|
||||||
<el-card shadow="never" class="workflow-card">
|
<el-card shadow="never" class="workflow-card">
|
||||||
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
|
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
|
||||||
@@ -337,16 +375,22 @@ onMounted(() => { void refresh() })
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never" class="workflow-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 #header><div class="card-title"><span>装柜五类证据与锁码</span><el-tag type="info">五类必传</el-tag></div></template>
|
||||||
<div class="evidence-grid">
|
<div class="evidence-grid">
|
||||||
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
|
<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 }}</strong>
|
||||||
<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)" />
|
<div class="evidence-control">
|
||||||
<el-input v-else :disabled="props.readOnly" :model-value="evidence(entry.type)?.fileName || ''" aria-label="照片文件名" placeholder="文件上传完成后填写文件名" @update:model-value="updateEvidenceField(entry.type, 'fileName', $event)" />
|
<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)" />
|
||||||
<el-button plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存</el-button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="workflow-hint">当前只保存证据元数据;真实文件上传需接 File 服务契约,不会把本地路径伪装成已上传文件。</p>
|
<p class="workflow-hint">五类证据各至少一张有效照片;锁码文本与带锁码照同时必填。错误照片只能作废并保留审计记录。</p>
|
||||||
<div class="workflow-actions">
|
<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>
|
<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>
|
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
|
||||||
@@ -354,17 +398,9 @@ onMounted(() => { void refresh() })
|
|||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-card v-if="isLoaded && props.showDelivery" shadow="never" class="workflow-card">
|
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
|
||||||
<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-alert>
|
||||||
<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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -381,13 +417,21 @@ onMounted(() => { void refresh() })
|
|||||||
.workflow-actions { margin-top: 14px; }
|
.workflow-actions { margin-top: 14px; }
|
||||||
.workflow-remark { margin-top: 10px; }
|
.workflow-remark { margin-top: 10px; }
|
||||||
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
.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 { display: grid; grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||||
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
.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; }
|
||||||
.workflow-alert { margin-bottom: 0; }
|
.workflow-alert { margin-bottom: 0; }
|
||||||
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
|
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
|
||||||
.evidence-item { grid-template-columns: 1fr; }
|
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
|
||||||
.workflow-actions > .el-button { flex: 1 1 150px; }
|
.workflow-actions > .el-button { flex: 1 1 150px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ onMounted(() => { void load() })
|
|||||||
v-else-if="summary && summary.inboundId"
|
v-else-if="summary && summary.inboundId"
|
||||||
:tenant-id="tenantId"
|
:tenant-id="tenantId"
|
||||||
:inbound-id="summary.inboundId"
|
:inbound-id="summary.inboundId"
|
||||||
|
:order-id="summary.orderId"
|
||||||
:main-status="summary.mainStatus"
|
:main-status="summary.mainStatus"
|
||||||
:inbound-version="summary.inboundVersion"
|
:inbound-version="summary.inboundVersion"
|
||||||
:picking-version="summary.loading?.pickingVersion"
|
:picking-version="summary.loading?.pickingVersion"
|
||||||
:delivery-tracking-no="summary.delivery?.trackingNo"
|
|
||||||
@updated="load"
|
@updated="load"
|
||||||
/>
|
/>
|
||||||
<el-empty v-else-if="!loading" description="该订单尚未生成入库作业,暂无后半程操作" />
|
<el-empty v-else-if="!loading" description="该订单尚未生成入库作业,暂无后半程操作" />
|
||||||
|
|||||||
Reference in New Issue
Block a user