This commit is contained in:
2026-08-10 15:36:53 +08:00
parent 5d97c7517a
commit f6556c57b5
54 changed files with 4107 additions and 170 deletions
@@ -1,17 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
completeLoading,
confirmingPreload,
deleteSmartLoadingItem,
getContainerBooking,
getOutboundDispatch,
getPicking,
getPreload,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
smartSplit,
uploadEvidenceFile,
voidEvidence,
} from './api'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn() },
http: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
@@ -19,6 +27,8 @@ describe('unified order workflow API contract', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.patch).mockReset()
vi.mocked(http.delete).mockReset()
})
it('uses the C03 preload endpoints and expected version', async () => {
@@ -75,4 +85,69 @@ describe('unified order workflow API contract', () => {
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
)
})
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 } })
.mockResolvedValueOnce({ data: [{
accountId: '2086085512165634052', inboundId: '2086085512165634053', inboundDetailId: '2086085512165634054',
orderId: '2086085512165634055', warehouseId: '2086085512165634051', availablePackageCount: '12',
unitWeightKg: '10.5', unitVolumeCbm: '0.25',
}] })
const container = await getContainerBooking('2086085512165634050')
const candidates = await listSmartLoadingCandidates(container.warehouseId!)
expect(container).toMatchObject({ id: '2086085512165634050', warehouseId: '2086085512165634051', capacityCbm: 68 })
expect(candidates[0]).toMatchObject({
accountId: '2086085512165634052', inboundId: '2086085512165634053',
inboundDetailId: '2086085512165634054', orderId: '2086085512165634055', availablePackageCount: 12,
})
})
it('normalizes preload and outbound aggregate identifiers as strings', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ data: {
id: '2086085512165634050', inboundId: '2086085512165634051', tenantId: '2086085512165634052',
status: 'PENDING', version: 1, items: [],
} })
.mockResolvedValueOnce({ data: [{
orderId: '2086085512165634053', inboundId: '2086085512165634051',
containerBookingId: '2086085512165634054', loadingPlanId: '2086085512165634055',
oceanBookingId: '2086085512165634056', truckAppointmentId: '2086085512165634057', gate: null,
}] })
const preload = await getPreload('2086085512165634052', '2086085512165634051')
const dispatch = await getOutboundDispatch('2086085512165634052', '2086085512165634053', '2086085512165634051')
expect(preload).toMatchObject({ id: '2086085512165634050', inboundId: '2086085512165634051', tenantId: '2086085512165634052' })
expect(dispatch).toMatchObject({
orderId: '2086085512165634053', containerBookingId: '2086085512165634054',
loadingPlanId: '2086085512165634055', oceanBookingId: '2086085512165634056', truckAppointmentId: '2086085512165634057',
})
})
it('uses optimistic locks and operation identifiers for smart-loading mutations', async () => {
const plan = { id: '91', bookingId: '81', status: 'EDITING', version: 4, items: [] }
vi.mocked(http.post).mockResolvedValue({ data: plan })
vi.mocked(http.patch).mockResolvedValue({ data: plan })
vi.mocked(http.delete).mockResolvedValue({ data: plan })
await assignSmartLoadingItem('91', { inboundId: '71', inboundDetailId: '72', packageCount: 3, expectedVersion: 4 })
await recommendSmartLoading('91', 4)
await adjustSmartLoadingItem('91', '92', { packageCount: 2, expectedPlanVersion: 4, expectedItemVersion: 1 })
await deleteSmartLoadingItem('91', '92', { expectedPlanVersion: 4, expectedItemVersion: 1 })
await pushSmartLoadingPreload('91', 4)
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans/91/assignments', {
inboundId: '71', inboundDetailId: '72', packageCount: 3, expectedVersion: 4,
operationId: expect.stringContaining('smart-loading-assign'),
})
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/smart-loading/plans/91/recommend', {
expectedVersion: 4, operationId: expect.stringContaining('smart-loading-recommend'),
})
expect(http.patch).toHaveBeenCalledWith('/platform/v1/first-leg/smart-loading/plans/91/items/92', expect.objectContaining({
expectedPlanVersion: 4, expectedItemVersion: 1, operationId: expect.stringContaining('smart-loading-adjust'),
}))
expect(http.delete).toHaveBeenCalledWith('/platform/v1/first-leg/smart-loading/plans/91/items/92', {
data: expect.objectContaining({ expectedPlanVersion: 4, expectedItemVersion: 1, operationId: expect.stringContaining('smart-loading-delete') }),
})
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/first-leg/smart-loading/plans/91/push-preload', { expectedVersion: 4 }, {
headers: { 'Idempotency-Key': expect.stringContaining('smart-loading-push') },
})
})
})
+294 -18
View File
@@ -4,21 +4,107 @@ 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
orderId: string | null
orderNo: string | null
inboundId: number | string | null
inboundId: string | null
inboundCode: string | null
containerBookingId: number | string | null
containerBookingId: string | null
containerNo: string | null
oceanBookingId: number | string | null
loadingPlanId: string | null
loadingPlanStatus: string | null
oceanBookingId: string | null
oceanBookingStatus: string | null
truckAppointmentId: number | string | null
truckAppointmentId: string | null
truckAppointmentStatus: string | null
customsStatus?: string | null
sailingStatus?: string | null
gate: { passed: boolean; items: OutboundGateItem[] } | null
}
export interface ContainerBooking {
id: string
containerNo: string | null
cabinetNo: string | null
warehouseId: string | null
warehouseName: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
capacityCbm: number | null
maxWeightKg: number | null
version: number
}
export interface SmartLoadingCandidate {
accountId: string
inboundId: string
inboundDetailId: string
orderId: string
orderNo: string | null
inboundCode: string | null
warehouseId: string
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
customerName: string | null
goodsName: string | null
availablePackageCount: number
unitWeightKg: number
unitVolumeCbm: number
}
export interface SmartLoadingPlanItem {
id: string
loadingPlanId: string
accountId: string
customerName: string | null
inboundId: string
inboundDetailId: string
orderId: string
orderNo: string | null
inboundCode: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
skuCode: string | null
productNameZh: string | null
packageCount: number
grossWeightKg: number
volumeCbm: number
loadSequence: number | null
version: number
}
export interface SmartLoadingPlan {
id: string
planNo: string | null
status: string
bookingId: string
containerNo: string | null
voyageNo: string | null
version: number
items: SmartLoadingPlanItem[]
}
export interface CreateSmartLoadingPlanRequest { bookingId: string; remark?: string }
export interface AssignSmartLoadingItemRequest {
inboundId: string
inboundDetailId: string
packageCount: number
expectedVersion: number
operationId: string
}
export interface RecommendSmartLoadingRequest { expectedVersion: number; operationId: string }
export interface AdjustSmartLoadingItemRequest {
packageCount: number
expectedPlanVersion: number
expectedItemVersion: number
operationId: string
}
export interface DeleteSmartLoadingItemRequest {
expectedPlanVersion: number
expectedItemVersion: number
operationId: 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'
@@ -32,9 +118,9 @@ export interface PreloadItem {
}
export interface PreloadPlan {
id: number | string
inboundId: number | string
tenantId: number | string
id: string
inboundId: string
tenantId: string
status: PreloadStatus
version: number
items: PreloadItem[]
@@ -139,24 +225,214 @@ function idempotencyKey(operation: string, inboundId: string | number) {
return `admin-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function operationId(operation: string, resourceId: string | number) {
return `admin-${operation}-${resourceId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function nullableId(value: number | string | null | undefined) {
return value === null || value === undefined || value === '' ? null : String(value)
}
function numberValue(value: number | string | null | undefined, fallback = 0) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function normalizeContainer(raw: Record<string, unknown>): ContainerBooking {
return {
id: String(raw.id),
containerNo: raw.containerNo == null ? null : String(raw.containerNo),
cabinetNo: raw.cabinetNo == null ? null : String(raw.cabinetNo),
warehouseId: nullableId(raw.warehouseId as number | string | null),
warehouseName: raw.warehouseName == null ? null : String(raw.warehouseName),
destinationWarehouseCode: raw.destinationWarehouseCode == null ? null : String(raw.destinationWarehouseCode),
destinationWarehouseName: raw.destinationWarehouseName == null ? null : String(raw.destinationWarehouseName),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm as number | string),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg as number | string),
version: numberValue(raw.version as number | string),
}
}
function normalizeCandidate(raw: Record<string, unknown>): SmartLoadingCandidate {
return {
accountId: String(raw.accountId), inboundId: String(raw.inboundId), inboundDetailId: String(raw.inboundDetailId),
orderId: String(raw.orderId), orderNo: raw.orderNo == null ? null : String(raw.orderNo),
inboundCode: raw.inboundCode == null ? null : String(raw.inboundCode), warehouseId: String(raw.warehouseId),
destinationWarehouseId: nullableId(raw.destinationWarehouseId as number | string | null),
destinationWarehouseCode: raw.destinationWarehouseCode == null ? null : String(raw.destinationWarehouseCode),
destinationWarehouseName: raw.destinationWarehouseName == null ? null : String(raw.destinationWarehouseName),
customerName: raw.customerName == null ? null : String(raw.customerName),
goodsName: raw.goodsName == null ? null : String(raw.goodsName),
availablePackageCount: numberValue(raw.availablePackageCount as number | string),
unitWeightKg: numberValue(raw.unitWeightKg as number | string), unitVolumeCbm: numberValue(raw.unitVolumeCbm as number | string),
}
}
function normalizeSmartLoadingItem(raw: Record<string, unknown>): SmartLoadingPlanItem {
return {
id: String(raw.id), loadingPlanId: String(raw.loadingPlanId), accountId: String(raw.accountId),
customerName: raw.customerName == null ? null : String(raw.customerName), inboundId: String(raw.inboundId),
inboundDetailId: String(raw.inboundDetailId), orderId: String(raw.orderId),
orderNo: raw.orderNo == null ? null : String(raw.orderNo), inboundCode: raw.inboundCode == null ? null : String(raw.inboundCode),
destinationWarehouseCode: raw.destinationWarehouseCode == null ? null : String(raw.destinationWarehouseCode),
destinationWarehouseName: raw.destinationWarehouseName == null ? null : String(raw.destinationWarehouseName),
skuCode: raw.skuCode == null ? null : String(raw.skuCode), productNameZh: raw.productNameZh == null ? null : String(raw.productNameZh),
packageCount: numberValue(raw.packageCount as number | string), grossWeightKg: numberValue(raw.grossWeightKg as number | string),
volumeCbm: numberValue(raw.volumeCbm as number | string), loadSequence: raw.loadSequence == null ? null : numberValue(raw.loadSequence as number | string),
version: numberValue(raw.version as number | string),
}
}
function normalizeSmartLoadingPlan(raw: Record<string, unknown>): SmartLoadingPlan {
return {
id: String(raw.id), planNo: raw.planNo == null ? null : String(raw.planNo), status: String(raw.status || ''),
bookingId: String(raw.bookingId), containerNo: raw.containerNo == null ? null : String(raw.containerNo),
voyageNo: raw.voyageNo == null ? null : String(raw.voyageNo), version: numberValue(raw.version as number | string),
items: Array.isArray(raw.items) ? raw.items.map((item) => normalizeSmartLoadingItem(item as Record<string, unknown>)) : [],
}
}
function normalizeOutboundDispatch(raw: Record<string, unknown>): OutboundDispatchAggregate {
return {
orderId: nullableId(raw.orderId as number | string | null),
orderNo: raw.orderNo == null ? null : String(raw.orderNo),
inboundId: nullableId(raw.inboundId as number | string | null),
inboundCode: raw.inboundCode == null ? null : String(raw.inboundCode),
containerBookingId: nullableId(raw.containerBookingId as number | string | null),
containerNo: raw.containerNo == null ? null : String(raw.containerNo),
loadingPlanId: nullableId(raw.loadingPlanId as number | string | null),
loadingPlanStatus: raw.loadingPlanStatus == null ? null : String(raw.loadingPlanStatus),
oceanBookingId: nullableId(raw.oceanBookingId as number | string | null),
oceanBookingStatus: raw.oceanBookingStatus == null ? null : String(raw.oceanBookingStatus),
truckAppointmentId: nullableId(raw.truckAppointmentId as number | string | null),
truckAppointmentStatus: raw.truckAppointmentStatus == null ? null : String(raw.truckAppointmentStatus),
customsStatus: raw.customsStatus == null ? null : String(raw.customsStatus),
sailingStatus: raw.sailingStatus == null ? null : String(raw.sailingStatus),
gate: raw.gate == null ? null : raw.gate as OutboundDispatchAggregate['gate'],
}
}
function normalizePreloadPlan(raw: Record<string, unknown>): PreloadPlan {
return {
id: String(raw.id), inboundId: String(raw.inboundId), tenantId: String(raw.tenantId),
status: String(raw.status || ''), version: numberValue(raw.version as number | string),
items: Array.isArray(raw.items) ? raw.items.map((item) => ({ ...(item as PreloadItem) })) : [],
remark: raw.remark == null ? null : String(raw.remark),
createdAt: raw.createdAt == null ? null : String(raw.createdAt),
updatedAt: raw.updatedAt == null ? null : String(raw.updatedAt),
}
}
function normalizeSplitAction(raw: Record<string, unknown>): SplitActionResponse {
return {
mainStatus: String(raw.mainStatus || ''),
preloadPlan: raw.preloadPlan == null ? null : normalizePreloadPlan(raw.preloadPlan as Record<string, unknown>),
}
}
const smartLoadingBasePath = '/platform/v1/first-leg/smart-loading'
export async function getContainerBooking(bookingId: string | number) {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`/platform/v1/containers/${bookingId}`,
)
return normalizeContainer(unwrapApiResponse(response))
}
export async function listSmartLoadingCandidates(warehouseId: string | number) {
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(
`${smartLoadingBasePath}/candidates`, { params: { warehouseId: String(warehouseId) } },
)
return unwrapApiResponse(response).map(normalizeCandidate)
}
export async function getSmartLoadingPlan(planId: string | number) {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}`,
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function getSmartLoadingPlanByBooking(bookingId: string | number) {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/by-booking/${bookingId}`,
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function createSmartLoadingPlan(bookingId: string | number, remark?: string) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans`, { bookingId: String(bookingId), remark: remark || undefined },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function assignSmartLoadingItem(planId: string | number, request: Omit<AssignSmartLoadingItemRequest, 'operationId'>) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/assignments`,
{ ...request, operationId: operationId('smart-loading-assign', planId) },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function recommendSmartLoading(planId: string | number, expectedVersion: number) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/recommend`,
{ expectedVersion, operationId: operationId('smart-loading-recommend', planId) },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function adjustSmartLoadingItem(
planId: string | number,
itemId: string | number,
request: Omit<AdjustSmartLoadingItemRequest, 'operationId'>,
) {
const response = await http.patch<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/items/${itemId}`,
{ ...request, operationId: operationId('smart-loading-adjust', itemId) },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function deleteSmartLoadingItem(
planId: string | number,
itemId: string | number,
request: Omit<DeleteSmartLoadingItemRequest, 'operationId'>,
) {
const response = await http.delete<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/items/${itemId}`,
{ data: { ...request, operationId: operationId('smart-loading-delete', itemId) } },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function pushSmartLoadingPreload(planId: string | number, expectedVersion: number) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/push-preload`, { expectedVersion },
{ headers: { 'Idempotency-Key': idempotencyKey('smart-loading-push', planId) } },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
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>>(
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload`, { params: query(tenantId) },
)
return unwrapApiResponse(response)
return normalizePreloadPlan(unwrapApiResponse(response))
}
export async function getOutboundDispatch(tenantId: string | number, orderId?: string | number, inboundId?: string | number) {
const response = await http.get<ApiEnvelope<OutboundDispatchAggregate[]>, ApiEnvelope<OutboundDispatchAggregate[]>>(
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(
'/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
return result?.[0] ? normalizeOutboundDispatch(result[0]) : null
}
export async function smartSplit(
@@ -164,10 +440,10 @@ export async function smartSplit(
inboundId: string | number,
request: SmartSplitRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/split/smart`, request, writeOptions('smart-split', inboundId, tenantId),
)
return unwrapApiResponse(response)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function confirmingPreload(
@@ -175,11 +451,11 @@ export async function confirmingPreload(
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
writeOptions('preload-confirming', inboundId, tenantId),
)
return unwrapApiResponse(response)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function confirmPreload(
@@ -187,11 +463,11 @@ export async function confirmPreload(
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
writeOptions('preload-confirm', inboundId, tenantId),
)
return unwrapApiResponse(response)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function getPicking(tenantId: string | number, inboundId: string | number) {
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Check, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
createSmartLoadingPlan,
deleteSmartLoadingItem,
getContainerBooking,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
type ContainerBooking,
type SmartLoadingCandidate,
type SmartLoadingPlan,
type SmartLoadingPlanItem,
} from '@/modules/unified-orders/workflows/api'
const props = defineProps<{
bookingId: string
highlightInboundId?: string | number | null
readOnly?: boolean
}>()
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const container = ref<ContainerBooking | null>(null)
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const action = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const assignOpen = ref(false)
const adjustOpen = ref(false)
const removeOpen = ref(false)
const pushOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const selectedItem = ref<SmartLoadingPlanItem | null>(null)
const packageCount = ref(1)
const filters = ref<QueryModel>({ keyword: '', eligibility: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const canView = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canCreate = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:push'))
const editable = computed(() => plan.value?.status === 'EDITING')
const usedVolume = computed(() => plan.value?.items.reduce((sum, item) => sum + item.volumeCbm, 0) ?? 0)
const usedWeight = computed(() => plan.value?.items.reduce((sum, item) => sum + item.grossWeightKg, 0) ?? 0)
const totalPackages = computed(() => plan.value?.items.reduce((sum, item) => sum + item.packageCount, 0) ?? 0)
const volumeRate = computed(() => utilization(usedVolume.value, container.value?.capacityCbm))
const weightRate = computed(() => utilization(usedWeight.value, container.value?.maxWeightKg))
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '候选货物', placeholder: '订单号、入仓码、客户或品名', props: { 'aria-label': '候选货物关键词' } },
{
key: 'eligibility', type: 'select', label: '可装状态', placeholder: '全部状态',
options: [{ label: '可加入', value: 'ELIGIBLE' }, { label: '存在冲突', value: 'CONFLICT' }],
},
]
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'priority', label: '推荐顺序', width: 92, slotName: 'priority' },
{ key: 'order', label: '订单/入仓码', minWidth: 180, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'goodsName', prop: 'goodsName', label: '品名', minWidth: 160 },
{ key: 'destination', label: '目的仓', minWidth: 150, slotName: 'destination' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'unitVolumeCbm', label: '单箱体积(m³)', width: 120, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量(kg)', width: 120, slotName: 'unitWeight' },
{ key: 'explanation', label: '建议与冲突说明', minWidth: 270, slotName: 'explanation' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'loadSequence', prop: 'loadSequence', label: '装柜顺序', width: 100 },
{ key: 'order', label: '订单/入仓码', minWidth: 180, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'productNameZh', prop: 'productNameZh', label: '品名', minWidth: 160 },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 90 },
{ key: 'grossWeightKg', label: '毛重(kg)', width: 110, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, slotName: 'volume' },
{ key: 'destination', label: '目的仓', minWidth: 150, slotName: 'destination' },
]
function utilization(used: number, capacity: number | null | undefined) {
return capacity && capacity > 0 ? Math.round(used / capacity * 1000) / 10 : null
}
function formatNumber(value: number, precision = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: precision }) : '—'
}
function routeMatches(candidate: SmartLoadingCandidate) {
const target = container.value?.destinationWarehouseCode
return !target || candidate.destinationWarehouseCode === target
}
function fittingCount(candidate: SmartLoadingCandidate) {
if (!routeMatches(candidate) || candidate.availablePackageCount <= 0) return 0
const volumeLeft = (container.value?.capacityCbm ?? 0) - usedVolume.value
const weightLeft = (container.value?.maxWeightKg ?? 0) - usedWeight.value
if (!container.value?.capacityCbm || !container.value.maxWeightKg) return 0
const byVolume = candidate.unitVolumeCbm > 0 ? Math.floor(Math.max(volumeLeft, 0) / candidate.unitVolumeCbm) : candidate.availablePackageCount
const byWeight = candidate.unitWeightKg > 0 ? Math.floor(Math.max(weightLeft, 0) / candidate.unitWeightKg) : candidate.availablePackageCount
return Math.max(0, Math.min(candidate.availablePackageCount, byVolume, byWeight))
}
function candidateExplanation(candidate: SmartLoadingCandidate) {
if (!routeMatches(candidate)) return `不可装:目的仓与柜次 ${container.value?.destinationWarehouseName || container.value?.destinationWarehouseCode || '目的仓'} 不一致`
if (!container.value?.capacityCbm || !container.value.maxWeightKg) return '不可装:柜次容量或最大承重尚未配置'
const count = fittingCount(candidate)
if (count <= 0) return '不可装:剩余容积或承重不足'
return `可装 ${count} 箱;同仓同目的仓,按入库完成时间先进先出推荐`
}
const filteredCandidates = computed(() => {
const keyword = String(appliedFilters.value.keyword || '').trim().toLowerCase()
const eligibility = String(appliedFilters.value.eligibility || '')
return candidates.value.filter((candidate) => {
const searchable = [candidate.orderNo, candidate.inboundCode, candidate.customerName, candidate.goodsName].join(' ').toLowerCase()
if (keyword && !searchable.includes(keyword)) return false
const eligible = fittingCount(candidate) > 0
return !eligibility || (eligibility === 'ELIGIBLE' ? eligible : !eligible)
})
})
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return error instanceof ApiResponseError && error.code === 'A0409'
|| details.code === 'A0409'
|| /版本|并发|已被其他人修改/.test(details.message)
}
async function load() {
if (!canView.value || !props.bookingId) return
loading.value = true
loadError.value = null
try {
const nextContainer = await getContainerBooking(props.bookingId)
container.value = nextContainer
try {
plan.value = await getSmartLoadingPlanByBooking(props.bookingId)
} catch (error) {
const details = getApiErrorDetails(error)
if (error instanceof ApiResponseError && ['A0404', 'NOT_FOUND'].includes(error.code)) plan.value = null
else throw Object.assign(error instanceof Error ? error : new Error(details.message), { details })
}
candidates.value = nextContainer.warehouseId ? await listSmartLoadingCandidates(nextContainer.warehouseId) : []
} catch (error) {
container.value = null
plan.value = null
candidates.value = []
loadError.value = getApiErrorDetails(error, '智能排柜数据加载失败,请稍后重试')
} finally {
loading.value = false
}
}
async function runAction(name: string, operation: () => Promise<SmartLoadingPlan>, success: string) {
action.value = name
actionError.value = null
conflictRefreshed.value = false
try {
plan.value = await operation()
ElMessage.success(success)
emit('updated')
if (container.value?.warehouseId) candidates.value = await listSmartLoadingCandidates(container.value.warehouseId)
return true
} catch (error) {
actionError.value = getApiErrorDetails(error, `${success}失败,请重试`)
if (isConflict(error)) {
await load()
conflictRefreshed.value = true
}
return false
} finally {
action.value = ''
}
}
async function createPlan() {
await runAction('create', () => createSmartLoadingPlan(props.bookingId), '装柜草稿已创建')
}
async function recommend() {
if (!plan.value) return
await runAction('recommend', () => recommendSmartLoading(plan.value!.id, plan.value!.version), '智能建议已生成')
}
function openAssign(candidate: SmartLoadingCandidate) {
selectedCandidate.value = candidate
packageCount.value = Math.max(1, fittingCount(candidate))
assignOpen.value = true
}
async function confirmAssign() {
if (!plan.value || !selectedCandidate.value) return
const candidate = selectedCandidate.value
const success = await runAction('assign', () => assignSmartLoadingItem(plan.value!.id, {
inboundId: candidate.inboundId,
inboundDetailId: candidate.inboundDetailId,
packageCount: packageCount.value,
expectedVersion: plan.value!.version,
}), '候选货物已加入装柜计划')
if (success) assignOpen.value = false
}
function openAdjust(item: SmartLoadingPlanItem) {
selectedItem.value = item
packageCount.value = item.packageCount
adjustOpen.value = true
}
async function confirmAdjust() {
if (!plan.value || !selectedItem.value) return
const item = selectedItem.value
const success = await runAction('adjust', () => adjustSmartLoadingItem(plan.value!.id, item.id, {
packageCount: packageCount.value,
expectedPlanVersion: plan.value!.version,
expectedItemVersion: item.version,
}), '装柜箱数已调整')
if (success) adjustOpen.value = false
}
function openRemove(item: SmartLoadingPlanItem) {
selectedItem.value = item
removeOpen.value = true
}
async function confirmRemove() {
if (!plan.value || !selectedItem.value) return
const item = selectedItem.value
const success = await runAction('remove', () => deleteSmartLoadingItem(plan.value!.id, item.id, {
expectedPlanVersion: plan.value!.version,
expectedItemVersion: item.version,
}), '装柜明细已移除')
if (success) removeOpen.value = false
}
async function confirmPush() {
if (!plan.value) return
const success = await runAction('push', () => pushSmartLoadingPreload(plan.value!.id, plan.value!.version), '装柜计划已推送预装')
if (success) pushOpen.value = false
}
function applyFilters(model: QueryModel) {
appliedFilters.value = { keyword: model.keyword || '', eligibility: model.eligibility || '' }
}
watch(() => [props.bookingId, access.permissionSignature], () => { void load() })
onMounted(() => { void load() })
</script>
<template>
<section class="smart-loading" aria-labelledby="smart-loading-title">
<header class="smart-loading__heading">
<div>
<h3 id="smart-loading-title">智能排柜</h3>
<p>{{ container?.cabinetNo || container?.containerNo || `柜次 ${bookingId}` }} · {{ container?.warehouseName || '仓库待加载' }}</p>
</div>
<div class="smart-loading__actions">
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
<el-button v-if="!plan" type="primary" :disabled="!canCreate || loading" :loading="action === 'create'" @click="createPlan"><Plus :size="15" />创建装柜草稿</el-button>
<el-button v-if="plan" :disabled="!canAssign || !editable || Boolean(action)" :loading="action === 'recommend'" @click="recommend"><Sparkles :size="15" />生成智能建议</el-button>
<el-button v-if="plan" type="primary" :disabled="!canPush || !editable || !plan.items.length || !container?.containerNo || Boolean(action)" @click="pushOpen = true"><Check :size="15" />推送预装</el-button>
</div>
</header>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>当前账号无智能排柜查询权限。</el-alert>
<LoadErrorState v-else-if="loadError" title="智能排柜暂时无法加载" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<el-alert v-if="actionError" class="smart-loading__error" type="error" :closable="false" show-icon>
<template #title>{{ actionError.message }}</template>
<p v-if="conflictRefreshed">系统已刷新最新计划,当前输入仍保留,请核对后重新提交。</p>
<ApiErrorMeta :details="actionError" />
</el-alert>
<div v-if="container" class="smart-loading__summary">
<div><span>计划状态</span><strong>{{ plan?.status || '尚未创建' }}</strong></div>
<div><span>已装箱数</span><strong>{{ totalPackages }}</strong></div>
<div><span>容积</span><strong>{{ formatNumber(usedVolume) }} / {{ formatNumber(container.capacityCbm || 0) }} m³</strong><small>{{ volumeRate == null ? '容量待配置' : `${volumeRate}%` }}</small></div>
<div><span>重量</span><strong>{{ formatNumber(usedWeight) }} / {{ formatNumber(container.maxWeightKg || 0) }} kg</strong><small>{{ weightRate == null ? '承重待配置' : `${weightRate}%` }}</small></div>
</div>
<section v-if="plan" class="smart-loading__section">
<div class="smart-loading__section-title"><h4>当前装柜方案</h4><span>版本 {{ plan.version }} · {{ plan.planNo || '—' }}</span></div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading" empty-text="装柜草稿暂无明细">
<template #actions="{ row }">
<el-button link type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)">调整</el-button>
<el-button link type="danger" :disabled="!canDelete || !editable || Boolean(action)" @click="openRemove(row as SmartLoadingPlanItem)"><Trash2 :size="13" />移除</el-button>
</template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</small></template>
<template #weight="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).grossWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).volumeCbm) }}</template>
<template #destination="{ row }">{{ (row as SmartLoadingPlanItem).destinationWarehouseName || (row as SmartLoadingPlanItem).destinationWarehouseCode || '—' }}</template>
</DataTable>
</section>
<section class="smart-loading__section">
<div class="smart-loading__section-title"><h4>候选货物</h4><span>后端按入库完成时间排序</span></div>
<QueryToolbar v-model="filters" ariaLabel="候选货物筛选" :fields="queryFields" @search="applyFilters" @reset="applyFilters" />
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
<template #actions="{ row }"><el-button link type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)">加入</el-button></template>
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</small></template>
<template #destination="{ row }">{{ (row as SmartLoadingCandidate).destinationWarehouseName || (row as SmartLoadingCandidate).destinationWarehouseCode || '—' }}</template>
<template #unitVolume="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitVolumeCbm) }}</template>
<template #unitWeight="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitWeightKg) }}</template>
<template #explanation="{ row }"><el-tag size="small" :type="fittingCount(row as SmartLoadingCandidate) > 0 ? 'success' : 'danger'">{{ fittingCount(row as SmartLoadingCandidate) > 0 ? '可加入' : '冲突' }}</el-tag><span>{{ candidateExplanation(row as SmartLoadingCandidate) }}</span></template>
</DataTable>
</section>
</template>
<AppDialog v-model="assignOpen" title="加入装柜方案" :confirm-loading="action === 'assign'" :confirm-disabled="packageCount < 1 || packageCount > (selectedCandidate ? fittingCount(selectedCandidate) : 0)" confirm-text="确认加入" @confirm="confirmAssign">
<p>{{ selectedCandidate?.orderNo || selectedCandidate?.inboundCode }} · 最多可加入 {{ selectedCandidate ? fittingCount(selectedCandidate) : 0 }} 箱</p>
<el-input-number v-model="packageCount" :min="1" :max="selectedCandidate ? fittingCount(selectedCandidate) : 1" controls-position="right" aria-label="加入箱数" />
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="adjustOpen" title="调整装柜箱数" :confirm-loading="action === 'adjust'" :confirm-disabled="packageCount < 1" confirm-text="保存调整" @confirm="confirmAdjust">
<p>{{ selectedItem?.orderNo || selectedItem?.inboundCode }} · 当前 {{ selectedItem?.packageCount || 0 }} 箱</p>
<el-input-number v-model="packageCount" :min="1" controls-position="right" aria-label="调整后箱数" />
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="removeOpen" title="移除装柜明细" confirm-type="danger" confirm-text="确认移除" :confirm-loading="action === 'remove'" @confirm="confirmRemove">
<p>将从当前草稿移除 {{ selectedItem?.orderNo || selectedItem?.inboundCode }} 的 {{ selectedItem?.packageCount || 0 }} 箱货物。此操作会写入审计历史。</p>
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="pushOpen" title="推送预装清单" confirm-type="primary" confirm-text="确认推送" :confirm-loading="action === 'push'" @confirm="confirmPush">
<p>推送后装柜计划将进入装柜中,当前草稿明细不可再增删调整。请确认柜号和货物分配无误。</p>
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
</section>
</template>
<style scoped>
.smart-loading { display: grid; gap: 14px; }
.smart-loading__heading, .smart-loading__actions, .smart-loading__section-title { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.smart-loading__heading, .smart-loading__section-title { justify-content: space-between; }
.smart-loading__heading h3, .smart-loading__section-title h4 { margin: 0; font-size: 15px; }
.smart-loading__heading p, .smart-loading__section-title span, .smart-loading small { display: block; margin: 3px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.smart-loading__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.smart-loading__summary > div { min-width: 0; padding: 10px; border: 1px solid var(--cc-border); border-radius: 5px; background: var(--el-fill-color-extra-light); }
.smart-loading__summary span { display: block; margin-bottom: 4px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.smart-loading__summary strong { display: block; overflow-wrap: anywhere; font-size: 13px; }
.smart-loading__section { display: grid; gap: 10px; padding-top: 4px; }
.smart-loading__error p { margin: 6px 0 0; }
.smart-loading :deep(.el-table__cell small), .smart-loading :deep(.el-table__cell strong) { display: block; }
.smart-loading :deep(.el-table__cell small) { margin-top: 2px; color: var(--cc-text-secondary, #64748b); }
.smart-loading :deep(.el-table__cell .el-tag + span) { margin-left: 6px; }
.priority { display: inline-grid; width: 26px; height: 26px; place-items: center; border-radius: 50%; background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 700; }
@media (max-width: 760px) {
.smart-loading__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.smart-loading__actions { width: 100%; }
.smart-loading__actions :deep(.el-button:not(.is-circle)) { flex: 1 1 150px; margin-left: 0; }
}
@media (max-width: 420px) { .smart-loading__summary { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,97 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
import {
getOutboundDispatch,
getPreload,
smartSplit,
} from '../api'
vi.mock('../api', async (importOriginal) => {
const original = await importOriginal<typeof import('../api')>()
return {
...original,
getOutboundDispatch: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
}
})
function preload(cargoReference: string, version = 3) {
return {
id: '2086085512165634050', inboundId: '2086085512165634051', tenantId: '2086085512165634052',
status: 'PENDING', version,
items: [{ containerCode: 'CAB-01', cargoReference, packageCount: 2, volumeCbm: 1.25, remark: null }],
remark: null, createdAt: null, updatedAt: null,
}
}
function mountPanel(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().permissions = permissions
return mount(UnifiedOrderWorkflowPanel, {
props: {
tenantId: '2086085512165634052', inboundId: '2086085512165634051', orderId: '2086085512165634053',
mainStatus: 'WAIT_SPLIT', inboundVersion: 4,
},
global: { plugins: [pinia, ElementPlus] },
})
}
describe('UnifiedOrderWorkflowPanel preload editor', () => {
beforeEach(() => {
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
})
it('renders the formal preload table and keeps the operation column first', async () => {
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
expect(getPreload).toHaveBeenCalledWith('2086085512165634052', '2086085512165634051')
expect(wrapper.text()).toContain('入库预装清单')
expect(wrapper.text()).toContain('REF-OLD')
const headers = wrapper.findAll('.el-table__header-wrapper th').map((node) => node.text())
expect(headers[0]).toContain('操作')
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(true)
})
it('does not query or expose editing controls without permission', async () => {
const wrapper = mountPanel([])
await flushPromises()
expect(getPreload).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('当前账号无预装清单查询权限')
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(false)
})
it('blocks an empty list before calling the real endpoint', async () => {
vi.mocked(getPreload).mockResolvedValue({ ...preload('REF-OLD'), items: [] })
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
const save = wrapper.findAll('button').find((button) => button.text().includes('保存分柜清单'))
expect(save).toBeTruthy()
await save!.trigger('click')
expect(smartSplit).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请至少添加一条预装明细')
})
it('refreshes the version but preserves submitted rows after a conflict', async () => {
vi.mocked(getPreload)
.mockResolvedValueOnce(preload('REF-OLD', 3))
.mockResolvedValueOnce(preload('REF-SERVER', 4))
vi.mocked(smartSplit).mockRejectedValue(new Error('数据已被其他人修改,请刷新后重试'))
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
const save = wrapper.findAll('button').find((button) => button.text().includes('保存分柜清单'))
await save!.trigger('click')
await flushPromises()
expect(getPreload).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('系统已刷新最新数据,请核对后重新提交')
expect(wrapper.text()).toContain('REF-OLD')
expect(wrapper.text()).not.toContain('REF-SERVER')
})
})
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera } from '@lucide/vue'
import { RefreshCw, Save, Check, Play, Camera, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import SmartLoadingWorkbench from '@/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue'
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '@/modules/unified-orders/workflows/preloadForm'
import {
completeLoading,
completePicking,
@@ -52,7 +58,14 @@ const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
const error = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null)
const preloadLoadError = ref<ApiErrorDetails | null>(null)
const notice = ref('')
const preloadDialogOpen = ref(false)
const preloadDialogMode = ref<'create' | 'edit'>('create')
const editingPreloadIndex = ref<number | null>(null)
const preloadDraft = ref<PreloadItem>(itemTemplate())
const preloadDraftError = ref('')
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
{ type: 'PHOTO_STEP_1', label: '空仓照' },
@@ -78,18 +91,62 @@ const canEvidence = computed(() => !props.readOnly && access.can('platform:first
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 preloadColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
{ key: 'containerCode', prop: 'containerCode', label: '柜号', minWidth: 150 },
{ key: 'cargoReference', prop: 'cargoReference', label: '货物引用', minWidth: 180 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 90 },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, slotName: 'volume' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
]
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
}
function addItem() {
preloadItems.value.push(itemTemplate())
preloadDialogMode.value = 'create'
editingPreloadIndex.value = null
preloadDraft.value = itemTemplate()
preloadDraftError.value = ''
preloadDialogOpen.value = true
}
function editItem(index: number) {
preloadDialogMode.value = 'edit'
editingPreloadIndex.value = index
preloadDraft.value = { ...preloadItems.value[index] }
preloadDraftError.value = ''
preloadDialogOpen.value = true
}
function saveItem() {
const validation = validatePreloadItem(preloadDraft.value)
if (!validation.valid) {
preloadDraftError.value = validation.message
return
}
const next = sanitizePreloadItem(preloadDraft.value)
if (preloadDialogMode.value === 'edit' && editingPreloadIndex.value !== null) {
preloadItems.value.splice(editingPreloadIndex.value, 1, next)
} else {
preloadItems.value.push(next)
}
preloadDialogOpen.value = false
}
function removeItem(index: number) {
preloadItems.value.splice(index, 1)
}
function preloadStatusLabel(status?: string | null) {
return ({ PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[status || ''] || '尚未生成'
}
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')
}
@@ -102,9 +159,10 @@ 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, restoreAfterConflict?: () => void) {
busy.value = action
error.value = ''
errorDetails.value = null
notice.value = ''
try {
const result = await operation()
@@ -112,7 +170,14 @@ async function run<T>(action: string, operation: () => Promise<T>, message: stri
emit('updated')
return result
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '操作失败,请重试'
const details = getApiErrorDetails(cause, '操作失败,请重试')
error.value = details.message
errorDetails.value = details
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await refresh()
restoreAfterConflict?.()
notice.value = '系统已刷新最新数据,请核对后重新提交'
}
return null
} finally {
busy.value = ''
@@ -123,6 +188,8 @@ async function loadPreload() {
if (!canPreloadView.value || !props.inboundId) return
loading.value = true
error.value = ''
errorDetails.value = null
preloadLoadError.value = null
try {
preload.value = await getPreload(props.tenantId, props.inboundId)
preloadItems.value = preload.value.items?.map((item) => ({ ...item })) || []
@@ -131,7 +198,9 @@ async function loadPreload() {
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 : '预装清单加载失败,请重试'
if (!missing) {
preloadLoadError.value = getApiErrorDetails(cause, '预装清单加载失败,请重试')
}
} finally {
loading.value = false
}
@@ -140,6 +209,7 @@ async function loadPreload() {
async function loadLoading() {
loading.value = true
error.value = ''
errorDetails.value = null
const messages: string[] = []
if (canPickingView.value) {
try {
@@ -157,26 +227,41 @@ async function loadLoading() {
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
}
}
error.value = messages.join(';')
loading.value = false
}
async function loadDispatch() {
try {
dispatch.value = await getOutboundDispatch(props.tenantId, props.orderId || undefined, props.inboundId)
} catch {
dispatch.value = null
}
error.value = messages.join(';')
loading.value = false
}
async function refresh() {
await loadDispatch()
if (isSplit.value) await loadPreload()
if (isLoading.value) await loadLoading()
}
async function runSmartSplit() {
const validation = validatePreloadItems(preloadItems.value)
if (!validation.valid) {
error.value = validation.message
errorDetails.value = null
return
}
const submittedItems = preloadItems.value.map((item) => sanitizePreloadItem(item))
const submittedRemark = splitRemark.value
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
items: preloadItems.value,
remark: splitRemark.value || undefined,
}), '智能分柜已提交')
items: submittedItems,
remark: submittedRemark.trim() || undefined,
}), '分柜清单已保存', () => {
preloadItems.value = submittedItems.map((item) => ({ ...item }))
splitRemark.value = submittedRemark
})
if (result) {
preload.value = result.preloadPlan
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
@@ -317,6 +402,7 @@ onMounted(() => { void refresh() })
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
<template #title>{{ error }}</template>
<ApiErrorMeta v-if="errorDetails" :details="errorDetails" />
<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>
@@ -336,25 +422,41 @@ onMounted(() => { void refresh() })
<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" :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>
</div>
<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="分柜备注(可选)" />
</div>
<template #header><div class="card-title"><span>入库预装清单</span><el-tag>{{ preloadStatusLabel(preload?.status) }}</el-tag></div></template>
<el-alert v-if="!canPreloadView" type="warning" :closable="false" show-icon>当前账号无预装清单查询权限。</el-alert>
<LoadErrorState v-else-if="preloadLoadError" title="预装清单暂时无法加载" :message="preloadLoadError.message" :loading="loading" @retry="loadPreload">
<template #details><ApiErrorMeta :details="preloadLoadError" /></template>
</LoadErrorState>
<template v-else>
<DataTable :data="preloadItems" :columns="preloadColumns" :loading="loading" empty-text="暂无预装明细,请先新增">
<template #actions="{ $index }">
<el-button link type="primary" :disabled="!canSplit || Boolean(busy)" :aria-label="`编辑第 ${Number($index) + 1} 条预装明细`" @click="editItem(Number($index))"><Pencil :size="14" />编辑</el-button>
<el-button link type="danger" :disabled="!canSplit || Boolean(busy)" :aria-label="`删除第 ${Number($index) + 1} 条预装明细`" @click="removeItem(Number($index))"><Trash2 :size="14" />删除</el-button>
</template>
<template #volume="{ row }">{{ formatVolume((row as PreloadItem).volumeCbm) }}</template>
<template #toolbar-start><div><strong>明细</strong><p>入库级清单由人工维护;柜次候选和智能建议在下方柜次工作台加载。</p></div></template>
<template #toolbar-actions><el-button :disabled="!canSplit || Boolean(busy)" @click="addItem"><Plus :size="15" />新增明细</el-button></template>
</DataTable>
<el-input v-model="splitRemark" :disabled="!canSplit || Boolean(busy)" class="workflow-remark" type="textarea" :rows="2" maxlength="1000" show-word-limit aria-label="分柜备注" placeholder="分柜备注(可选)" />
</template>
<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 :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
<el-button :disabled="!canPreload || !preload || preload.status !== 'PENDING' || 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>
<el-card v-if="dispatch?.containerBookingId" shadow="never" class="workflow-card">
<SmartLoadingWorkbench
:booking-id="String(dispatch.containerBookingId)"
:highlight-inbound-id="props.inboundId"
:read-only="props.readOnly"
@updated="refresh"
/>
</el-card>
<el-alert v-else type="info" :closable="false" show-icon>
当前订单尚未关联柜次装柜计划;请先在货柜智能排柜工作台选择柜次并分配货物。
</el-alert>
</template>
<template v-if="isLoading">
@@ -401,6 +503,29 @@ onMounted(() => { void refresh() })
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
</el-alert>
<AppDialog
v-model="preloadDialogOpen"
:title="preloadDialogMode === 'create' ? '新增预装明细' : '编辑预装明细'"
confirm-text="保存明细"
:confirm-disabled="Boolean(busy)"
@confirm="saveItem"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="柜号" required :error="preloadDraftError === '柜号不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.containerCode" maxlength="64" placeholder="请输入柜号" @input="preloadDraftError = ''" />
</el-form-item>
<el-form-item label="货物引用" required :error="preloadDraftError === '货物引用不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="订单号、入仓码或业务引用" @input="preloadDraftError = ''" />
</el-form-item>
<div class="preload-dialog-grid">
<el-form-item label="箱数" required><el-input-number v-model="preloadDraft.packageCount" :min="1" :step="1" step-strictly controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
<el-form-item label="体积(m³)"><el-input-number v-model="preloadDraft.volumeCbm" :min="0" :precision="3" controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
</div>
<el-form-item label="明细备注"><el-input v-model="preloadDraft.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
<el-alert v-if="preloadDraftError" type="error" :closable="false" show-icon>{{ preloadDraftError }}</el-alert>
</el-form>
</AppDialog>
</section>
</template>
@@ -412,8 +537,7 @@ onMounted(() => { void refresh() })
.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; }
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
@@ -430,7 +554,8 @@ onMounted(() => { void refresh() })
.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; }
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
.preload-dialog-grid { gap: 0; }
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from './preloadForm'
describe('preload form contract', () => {
it('requires a meaningful row before submitting the real smart-split endpoint', () => {
expect(validatePreloadItems([])).toEqual({ valid: false, message: '请至少添加一条预装明细' })
expect(validatePreloadItem({ containerCode: '', cargoReference: 'SKU-1', packageCount: 1, volumeCbm: 0, remark: null }).message)
.toBe('柜号不能为空')
expect(validatePreloadItem({ containerCode: 'C-1', cargoReference: '', packageCount: 1, volumeCbm: 0, remark: null }).message)
.toBe('货物引用不能为空')
expect(validatePreloadItem({ containerCode: 'C-1', cargoReference: 'SKU-1', packageCount: 1.5, volumeCbm: 0, remark: null }).message)
.toBe('箱数必须为大于 0 的整数')
})
it('normalizes whitespace and numeric values without changing identifier strings', () => {
expect(sanitizePreloadItem({
containerCode: ' 2086085512165634050 ', cargoReference: ' REF-1 ', packageCount: 2, volumeCbm: 1.25, remark: ' note ',
})).toEqual({
containerCode: '2086085512165634050', cargoReference: 'REF-1', packageCount: 2, volumeCbm: 1.25, remark: 'note',
})
})
})
@@ -0,0 +1,38 @@
import type { PreloadItem } from './api'
export interface PreloadItemValidation {
valid: boolean
message: string
}
export function validatePreloadItem(item: PreloadItem): PreloadItemValidation {
if (!item.containerCode?.trim()) return { valid: false, message: '柜号不能为空' }
if (!item.cargoReference?.trim()) return { valid: false, message: '货物引用不能为空' }
if (!Number.isInteger(item.packageCount) || Number(item.packageCount) <= 0) {
return { valid: false, message: '箱数必须为大于 0 的整数' }
}
if (item.volumeCbm !== null && (!Number.isFinite(Number(item.volumeCbm)) || Number(item.volumeCbm) < 0)) {
return { valid: false, message: '体积不能小于 0' }
}
if ((item.remark?.length || 0) > 500) return { valid: false, message: '明细备注不能超过 500 个字符' }
return { valid: true, message: '' }
}
export function validatePreloadItems(items: PreloadItem[]): PreloadItemValidation {
if (!items.length) return { valid: false, message: '请至少添加一条预装明细' }
for (let index = 0; index < items.length; index += 1) {
const result = validatePreloadItem(items[index])
if (!result.valid) return { valid: false, message: `第 ${index + 1} 条:${result.message}` }
}
return { valid: true, message: '' }
}
export function sanitizePreloadItem(item: PreloadItem): PreloadItem {
return {
containerCode: item.containerCode?.trim() || null,
cargoReference: item.cargoReference?.trim() || null,
packageCount: Number(item.packageCount),
volumeCbm: item.volumeCbm === null ? 0 : Number(item.volumeCbm),
remark: item.remark?.trim() || null,
}
}