优化
This commit is contained in:
@@ -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') },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user