优化
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# C06 Admin 后半程作业
|
||||
|
||||
本目录只承载统一订单详情的后半程作业接入点,供 C05 统一订单列表在详情抽屉或作业页中装配。
|
||||
|
||||
真实接口均使用 `/api/platform/v1/inbounds/{inboundId}`:
|
||||
|
||||
- `POST /split/smart`:智能分柜/保存预装清单
|
||||
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
|
||||
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
|
||||
- `GET/POST /loading/evidence`:四步照片与封签证据元数据
|
||||
- `POST /loading/complete`:完成装柜
|
||||
- `POST /delivery/complete`:确认派送完成
|
||||
|
||||
所有写请求带 `Idempotency-Key` 和服务端要求的 `expectedVersion`。`loading/complete` 与 `delivery/complete` 使用入库单版本;拣货使用拣货任务版本。当前没有 File 服务上传接口,证据组件不会把本地路径伪装成已上传文件。
|
||||
|
||||
权限码、状态流转和请求字段以 Firstleg Controller/DTO 为准;本模块不新增“进入分柜”操作,也不定义通用 `/actions/{ACTION}` 接口。
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
completeDelivery,
|
||||
completeLoading,
|
||||
confirmingPreload,
|
||||
getPicking,
|
||||
getPreload,
|
||||
smartSplit,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('unified order workflow API contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
})
|
||||
|
||||
it('uses the C03 preload endpoints and expected version', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { id: 1, inboundId: 8, tenantId: 2, status: 'PENDING', version: 3, items: [] } })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
|
||||
await getPreload('2', '8')
|
||||
await smartSplit('2', '8', { expectedVersion: 3, items: [] })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/8/preload', { params: { tenantId: '2' } })
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/8/split/smart', { expectedVersion: 3, items: [] },
|
||||
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses C04 picking and loading paths without a generic action endpoint', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { id: 1, inboundId: 8, status: 'PICKING', version: 4 } })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'LOADED', evidences: null } })
|
||||
await getPicking('2', '8')
|
||||
await completeLoading('2', '8', { expectedVersion: 7 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/8/loading/picking', { params: { tenantId: '2' } })
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/8/loading/complete', { expectedVersion: 7 },
|
||||
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
|
||||
)
|
||||
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 () => {
|
||||
vi.mocked(http.get).mockRejectedValue(new Error('无权限'))
|
||||
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
|
||||
})
|
||||
|
||||
it('supports the intermediate confirming preload action', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
|
||||
await confirmingPreload('2', '8', { expectedVersion: 1 })
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/8/preload/confirming', { expectedVersion: 1 },
|
||||
expect.objectContaining({ params: { tenantId: '2' } }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
const inboundBasePath = '/platform/v1/inbounds'
|
||||
|
||||
export type PreloadStatus = 'PENDING' | 'CONFIRMING' | 'CONFIRMED' | string
|
||||
export type EvidenceType = 'PHOTO_STEP_1' | 'PHOTO_STEP_2' | 'PHOTO_STEP_3' | 'PHOTO_STEP_4' | 'SEAL'
|
||||
|
||||
export interface PreloadItem {
|
||||
containerCode: string | null
|
||||
cargoReference: string | null
|
||||
packageCount: number | null
|
||||
volumeCbm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface PreloadPlan {
|
||||
id: number | string
|
||||
inboundId: number | string
|
||||
tenantId: number | string
|
||||
status: PreloadStatus
|
||||
version: number
|
||||
items: PreloadItem[]
|
||||
remark: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface SplitActionResponse {
|
||||
mainStatus: string
|
||||
preloadPlan: PreloadPlan | null
|
||||
}
|
||||
|
||||
export interface PickingTask {
|
||||
id: number | string
|
||||
inboundId: number | string
|
||||
status: string
|
||||
version: number
|
||||
expectedCount: number | null
|
||||
pickedCount: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface Evidence {
|
||||
id: number | string
|
||||
inboundId: number | string
|
||||
tenantId: number | string
|
||||
evidenceType: EvidenceType
|
||||
fileId: number | string | null
|
||||
fileName: string | null
|
||||
sealNo: string | null
|
||||
remark: string | null
|
||||
version: number
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface EvidenceList {
|
||||
inboundId: number | string
|
||||
evidences: Evidence[]
|
||||
}
|
||||
|
||||
export interface LoadingActionResponse {
|
||||
mainStatus: string
|
||||
evidences: EvidenceList | null
|
||||
}
|
||||
|
||||
export interface PickingRequest {
|
||||
expectedVersion: number
|
||||
expectedCount: number
|
||||
pickedCount: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface SmartSplitRequest {
|
||||
expectedVersion: number
|
||||
items: PreloadItem[]
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ConfirmPreloadRequest {
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export interface UpsertEvidenceRequest {
|
||||
expectedVersion: number
|
||||
evidenceType: EvidenceType
|
||||
fileId?: number | string
|
||||
fileName?: string
|
||||
sealNo?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface CompleteLoadingRequest {
|
||||
expectedVersion: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface CompleteDeliveryRequest {
|
||||
expectedVersion: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
function query(tenantId: string | number) {
|
||||
return { tenantId: String(tenantId) }
|
||||
}
|
||||
|
||||
function idempotencyKey(operation: string, inboundId: string | number) {
|
||||
return `admin-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function writeOptions(operation: string, inboundId: string | number, tenantId: string | number) {
|
||||
return { params: query(tenantId), headers: { 'Idempotency-Key': idempotencyKey(operation, inboundId) } }
|
||||
}
|
||||
|
||||
export async function getPreload(tenantId: string | number, inboundId: string | number) {
|
||||
const response = await http.get<ApiEnvelope<PreloadPlan>, ApiEnvelope<PreloadPlan>>(
|
||||
`${inboundBasePath}/${inboundId}/preload`, { params: query(tenantId) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function smartSplit(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: SmartSplitRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/split/smart`, request, writeOptions('smart-split', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function confirmingPreload(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: ConfirmPreloadRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
|
||||
writeOptions('preload-confirming', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function confirmPreload(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: ConfirmPreloadRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
|
||||
writeOptions('preload-confirm', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getPicking(tenantId: string | number, inboundId: string | number) {
|
||||
const response = await http.get<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/picking`, { params: query(tenantId) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function startPicking(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: PickingRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/picking/start`, request,
|
||||
writeOptions('picking-start', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function completePicking(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: PickingRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/picking/complete`, request,
|
||||
writeOptions('picking-complete', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getEvidence(tenantId: string | number, inboundId: string | number) {
|
||||
const response = await http.get<ApiEnvelope<EvidenceList>, ApiEnvelope<EvidenceList>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/evidence`, { params: query(tenantId) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function upsertEvidence(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: UpsertEvidenceRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/evidence`, request,
|
||||
writeOptions(`evidence-${request.evidenceType}`, inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function completeLoading(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: CompleteLoadingRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/complete`, request,
|
||||
writeOptions('loading-complete', inboundId, tenantId),
|
||||
)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
<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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as UnifiedOrderWorkflowPanel } from './components/UnifiedOrderWorkflowPanel.vue'
|
||||
export { default as UnifiedOrderWorkflowView } from './pages/UnifiedOrderWorkflowView.vue'
|
||||
export * from './api'
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getPlatformUnifiedOrder, type UnifiedOrderDetailResource } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import UnifiedOrderWorkflowPanel from '@/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const detail = ref<UnifiedOrderDetailResource | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const tenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
|
||||
const summary = computed(() => detail.value?.summary || null)
|
||||
|
||||
async function load() {
|
||||
if (!orderId.value || !tenantId.value) {
|
||||
error.value = { message: '订单作业上下文缺少订单号或客户标识' }
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
detail.value = await getPlatformUnifiedOrder(tenantId.value, orderId.value)
|
||||
} catch (cause) {
|
||||
detail.value = null
|
||||
error.value = getApiErrorDetails(cause, '订单作业上下文加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workflow-page">
|
||||
<LoadErrorState v-if="error" title="后半程作业暂时无法加载" :message="error.message" :loading="loading" @retry="load">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
<UnifiedOrderWorkflowPanel
|
||||
v-else-if="summary && summary.inboundId"
|
||||
:tenant-id="tenantId"
|
||||
:inbound-id="summary.inboundId"
|
||||
:main-status="summary.mainStatus"
|
||||
:inbound-version="summary.inboundVersion"
|
||||
:picking-version="summary.loading?.pickingVersion"
|
||||
:delivery-tracking-no="summary.delivery?.trackingNo"
|
||||
@updated="load"
|
||||
/>
|
||||
<el-empty v-else-if="!loading" description="该订单尚未生成入库作业,暂无后半程操作" />
|
||||
<el-skeleton v-else :rows="5" animated />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-page { display: grid; gap: 16px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user