Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
21 changed files with 300 additions and 52 deletions
Showing only changes of commit 7ba81007a8 - Show all commits
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
@@ -91,6 +91,17 @@ describe('platform container loading API', () => {
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
})
it('assigns a sealed pallet through the whole-pallet command', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
await assignSmartLoadingPallet('10', { palletId: '9007199254740998', expectedVersion: 5 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/pallet-assignments',
{ palletId: '9007199254740998', expectedVersion: 5, operationId: expect.stringContaining('assign-pallet-') },
)
})
it('uses the dedicated provisional assignment command for a pending-review order', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
@@ -101,6 +101,8 @@ function item(raw: SmartLoadingItem): SmartLoadingItem {
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), accountId: id(raw.accountId),
inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId),
destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version),
palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null,
unitType: raw.unitType, indivisible: Boolean(raw.indivisible),
packageCount: numberValue(raw.packageCount),
pickedPackageCount: numberValue(raw.pickedPackageCount),
minPackageCount: numberValue(raw.minPackageCount, 1),
@@ -267,7 +269,8 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue
return {
...raw, accountId, customerId: accountId, inboundId: optionalId(raw.inboundId),
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
coLoadGroupId: optionalId(raw.coLoadGroupId),
coLoadGroupId: optionalId(raw.coLoadGroupId), palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null,
unitType: raw.unitType, indivisible: Boolean(raw.indivisible),
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
locked: raw.locked == null ? null : Boolean(raw.locked),
@@ -317,6 +320,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingPallet(planId: string, payload: { palletId: string; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pallet-assignments`,
{ ...payload, operationId: operationId('assign-pallet') },
)
return plan(unwrapApiResponse(response))
}
export async function assignPendingReviewSmartLoadingOrder(planId: string, payload: { customerId: string; orderId: string; inboundCode: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pending-review-assignments`,
@@ -13,6 +13,7 @@ import {
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
assignSmartLoadingPallet,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
@@ -43,6 +44,7 @@ vi.mock('../api/platformContainerLoading', () => ({
listSmartLoadingWorkbenchContainers: vi.fn(),
createSmartLoadingPlan: vi.fn(),
assignSmartLoadingItem: vi.fn(),
assignSmartLoadingPallet: vi.fn(),
assignPendingReviewSmartLoadingOrder: vi.fn(),
assignSmartLoadingGroup: vi.fn(),
recommendSmartLoading: vi.fn(),
@@ -74,7 +76,7 @@ const plan = {
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
}
const candidate = {
accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2',
@@ -89,7 +91,7 @@ function candidatePage(records: unknown[], page = 1, size = 20, total = records.
return {
records: records.map((record) => {
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
}),
page, size, total, pages: total ? Math.ceil(total / size) : 0,
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
@@ -116,7 +118,8 @@ describe('GlobalSmartLoadingView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%; }')
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
@@ -18,6 +18,7 @@ import {
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
assignSmartLoadingPallet,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
listSmartLoadingWorkbenchContainers,
@@ -97,6 +98,7 @@ const warehouseOptions = computed(() => {
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const assignedPalletIds = computed(() => new Set((plan.value?.items || []).map((item) => item.palletId).filter(Boolean)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
@@ -114,6 +116,7 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) {
if (candidate.unitType === 'SEALED_PALLET') return candidate.unitGrossWeightKg
return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0)
}
function candidateTotalVolume(candidate: SmartLoadingCandidate) {
@@ -221,20 +224,23 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'unitType', label: '分配单位', width: 105, slotName: 'unitType' },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 },
// { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
{ key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
{ key: 'totalGrossWeightKg', label: '重量', width: 90, slotName: 'candidateTotalWeight' },
{ key: 'averageUnitVolumeCbm', label: '单箱体积', width: 80, slotName: 'averageUnitVolume' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
{ key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' },
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
@@ -490,7 +496,8 @@ function openAssign(source: object) {
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (!plan.value || !selectedCandidate.value) return
if (selectedCandidate.value.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
if (!selectedCandidate.value.coLoadGroupId && assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
@@ -498,7 +505,9 @@ async function assign() {
}
await mutate(async () => {
const selected = selectedCandidate.value!
acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW'
acceptPlan(selected.unitType === 'SEALED_PALLET' && selected.palletId
? await assignSmartLoadingPallet(plan.value!.id, { palletId: selected.palletId, expectedVersion: plan.value!.version })
: selected.orderStatusSnapshot === 'PENDING_REVIEW'
? await assignPendingReviewSmartLoadingOrder(plan.value!.id, {
customerId: selected.accountId,
orderId: selected.orderId,
@@ -519,6 +528,10 @@ async function assign() {
}, '分配明细失败', { preserveAssign: true })
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.unitType === 'SEALED_PALLET') {
return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.palletId
|| !candidate.indivisible || assignedPalletIds.value.has(candidate.palletId)
}
if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') {
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1
@@ -547,6 +560,7 @@ async function recommend() {
}
async function adjust(source: object) {
const item = itemRow(source)
if (item.unitType === 'SEALED_PALLET') return
if (!plan.value || mutating.value) return
const count = Number(quantityDrafts[item.id])
if (count === item.packageCount) return
@@ -712,10 +726,10 @@ onMounted(() => { void load(); void loadCustomers() })
plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
><Plus :size="15" />{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
</template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
@@ -789,8 +803,9 @@ onMounted(() => { void load(); void loadCustomers() })
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else content="普通单票" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable>
@@ -798,7 +813,7 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
</section>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
@@ -810,6 +825,10 @@ onMounted(() => { void load(); void loadCustomers() })
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
@@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
import { assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
import { assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingPallet: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
@@ -36,7 +36,7 @@ function candidatePage(records: unknown[]) {
return {
records: records.map((record) => {
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
}),
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
@@ -46,7 +46,8 @@ describe('SmartLoadingWorkbenchView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;')
expect(source).toContain('<template #goods="{ row }"><div class="goods-cell"><LineClampText')
@@ -201,4 +202,28 @@ describe('SmartLoadingWorkbenchView', () => {
wrapper.unmount()
})
it('assigns a sealed pallet atomically without a package-count input', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, unitType: 'SEALED_PALLET', palletId: '9007199254740998', palletNo: 'DP-260908-0001',
indivisible: true, availablePackageCount: 12,
}]))
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('DP-260908-0001')
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不可拆分单元整托加入')
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(planResponse.id, { palletId: '9007199254740998', expectedVersion: planResponse.version })
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -12,7 +12,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
@@ -46,6 +46,7 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const assignedPalletIds = computed(() => new Set((plan.value?.items || []).map((item) => item.palletId).filter(Boolean)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
@@ -130,6 +131,7 @@ const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
@@ -140,13 +142,15 @@ const itemColumns: DataTableColumn[] = [
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'unitType', label: '分配单位', width: 110, slotName: 'unitType' },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, slotName: 'goodsSummary' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 90 },
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
{ key: 'averageUnitVolumeCbm', label: '平均单箱体积', width: 145, slotName: 'averageUnitVolume' },
{ key: 'unitGrossWeightKg', label: '平均单箱毛重', width: 140, slotName: 'unitGrossWeight' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 120, slotName: 'averageUnitVolume' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 120, slotName: 'unitGrossWeight' },
]
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
@@ -231,12 +235,15 @@ async function assign() {
const currentPlan = plan.value
const selected = selectedCandidate.value
const inboundId = selected?.inboundId
if (!currentPlan || !selected || !inboundId || assignForm.packageCount < 1) return
if (!currentPlan || !selected || !inboundId) return
if (selected.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return
if (selected.coLoadGroupId && (selected.blockReason || selected.locked)) return
mutating.value = true
mutationError.value = null
try {
const next = selected.coLoadGroupId
const next = selected.unitType === 'SEALED_PALLET' && selected.palletId
? await assignSmartLoadingPallet(currentPlan.id, { palletId: selected.palletId, expectedVersion: currentPlan.version })
: selected.coLoadGroupId
? await assignSmartLoadingGroup(currentPlan.id, inboundId, currentPlan.version)
: await assignSmartLoadingItem(currentPlan.id, { inboundId, packageCount: assignForm.packageCount, expectedVersion: currentPlan.version })
await acceptPlanWithGroups(next)
@@ -246,6 +253,10 @@ async function assign() {
finally { mutating.value = false }
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.unitType === 'SEALED_PALLET') {
return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.palletId
|| !candidate.indivisible || assignedPalletIds.value.has(candidate.palletId)
}
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundId
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
@@ -272,6 +283,7 @@ async function recommend() {
}
async function adjust(source: object) {
const item = itemRow(source)
if (item.unitType === 'SEALED_PALLET') return
if (!plan.value || mutating.value) return
const count = Number(quantityDrafts[item.id])
if (count === item.packageCount) return
@@ -372,24 +384,25 @@ onMounted(load)
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #goods="{ row }"><div class="goods-cell"><LineClampText :content="row.productNameZh || '未命名货物'" /><LineClampText class="goods-cell__sku" :content="row.skuCode || '无 SKU'" /></div></template>
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #goodsSummary="{ row }"><LineClampText :content="row.goodsSummary || '—'" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag></template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</template>
</template>
</template>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
</section>
</AdminPage>
</template>
@@ -7,7 +7,7 @@ function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
id: '1', loadingPlanId: '10', accountId: '20', inboundId: '30', inboundDetailId: '40',
orderId: '50', orderNo: 'FL-1', inboundCode: 'NYEXX2608180003', customerName: '柏悦物流',
destinationWarehouseId: '60', destinationWarehouseCode: 'LAX', destinationWarehouseName: '洛杉矶仓',
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 2, productQuantity: 20,
skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, packageCount: 2, productQuantity: 20,
grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 2, version: 1,
pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
...overrides,
@@ -15,6 +15,17 @@ function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
}
describe('groupSmartLoadingItems', () => {
it('keeps every sealed pallet as an independent non-adjustable allocation unit', () => {
const grouped = groupSmartLoadingItems([
item({ id: 'p1', unitType: 'SEALED_PALLET', palletId: '101', palletNo: 'PLT-101', indivisible: true, packageCount: 4 }),
item({ id: 'p2', unitType: 'SEALED_PALLET', palletId: '102', palletNo: 'PLT-102', indivisible: true, packageCount: 6 }),
])
expect(grouped).toHaveLength(2)
expect(grouped.map((row) => row.palletNo)).toEqual(['PLT-101', 'PLT-102'])
expect(grouped.every((row) => row.canAdjust === false)).toBe(true)
})
it('shows multiple specification rows as one inbound-code row with summed quantities', () => {
const grouped = groupSmartLoadingItems([
item(),
@@ -21,7 +21,9 @@ function joinDistinct(items: SmartLoadingItem[], value: (item: SmartLoadingItem)
export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingInboundItem[] {
const groups = new Map<string, SmartLoadingItem[]>()
for (const item of items) {
const key = `${item.accountId}:${item.inboundId}`
const key = item.unitType === 'SEALED_PALLET'
? `${item.accountId}:${item.inboundId}:pallet:${item.palletId}`
: `${item.accountId}:${item.inboundId}:packages`
groups.set(key, [...(groups.get(key) || []), item])
}
@@ -44,7 +46,7 @@ export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingI
minPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.minPackageCount || 0), 0),
totalPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.totalPackageCount || item.packageCount || 0), 0),
remainingPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.remainingPackageCount || 0), 0),
canAdjust: sourceItems.length === 1 && first.canAdjust,
canAdjust: first.unitType !== 'SEALED_PALLET' && sourceItems.length === 1 && first.canAdjust,
canRemove: sourceItems.every((item) => item.canRemove),
sourceItems,
detailCount: sourceItems.length,
@@ -5,7 +5,7 @@ import type { SmartLoadingPlan } from './types'
const item = {
id: '701', loadingPlanId: '99', accountId: '1', inboundId: '201', inboundDetailId: '801', orderId: '301',
orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '客户一', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9',
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
}
+8
View File
@@ -246,6 +246,10 @@ export interface SmartLoadingCandidate {
destinationWarehouseName: string | null
customerName: string | null
goodsSummary: string | null
unitType: 'PACKAGE' | 'SEALED_PALLET'
palletId: string | null
palletNo: string | null
indivisible: boolean
availablePackageCount: number
unitGrossWeightKg: number | null
unitNetWeightKg: number | null
@@ -336,6 +340,10 @@ export interface SmartLoadingItem {
destinationWarehouseName: string | null
skuCode: string | null
productNameZh: string | null
unitType: 'PACKAGE' | 'SEALED_PALLET'
palletId: string | null
palletNo: string | null
indivisible: boolean
packageCount: number
productQuantity: number | null
grossWeightKg: number | null
@@ -19,7 +19,8 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('label="测量审核"')
expect(source).toContain('label="费用信息"')
expect(source).toContain('label="状态历史"')
expect(source).toContain('label="其他记录"')
expect(source).toContain("detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'")
expect(source).toContain('listPlatformInboundDropshipPallets(props.customerId, props.inboundId)')
})
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
@@ -6,7 +6,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformInbound } from '../core/api'
import { getPlatformInbound, listPlatformInboundDropshipPallets } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundOrderDetail } from '../core/types'
@@ -37,6 +37,7 @@ let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || '入仓码待补充')
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const canViewDropshipPallets = computed(() => access.can('platform:firstleg:pda:view'))
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
async function loadDetail() {
@@ -51,6 +52,9 @@ async function loadDetail() {
loadError.value = null
try {
const response = await getPlatformInbound(props.customerId, props.inboundId)
if (response.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' && canViewDropshipPallets.value) {
response.dropshipPallets = await listPlatformInboundDropshipPallets(props.customerId, props.inboundId)
}
if (disposed || serial !== requestSerial) return
detail.value = response
} catch (error) {
@@ -174,12 +178,13 @@ onBeforeUnmount(() => {
:sections="['history']"
/>
</el-tab-pane>
<el-tab-pane label="其他记录" name="records" lazy>
<el-tab-pane :label="detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'" name="records" lazy>
<InboundHistoryPanel
:pallet-locations="detail.palletLocations"
:dropship-pallets="detail.dropshipPallets"
:customer-id="customerId"
:inbound-id="inboundId"
:can-view-dropship="canViewDropshipPallets"
:files="detail.files"
:internal-notes="detail.internalNotes"
:status-history="[]"
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/core/InboundHistoryPanel.vue'), 'utf8')
describe('InboundHistoryPanel dropship pallet result', () => {
it('shows the frozen pallet, carton and discrepancy facts', () => {
for (const marker of [
'一件代发清点与组托结果', 'pallet.palletNo', 'pallet.status', 'pallet.cartonCount',
'pallet.skuCount', 'pallet.weightKg', 'pallet.volumeCbm', 'pallet.discrepancyCartons', 'pallet.sealedAt',
'item.inboundCode', 'item.skuCode', 'item.cargoName', 'item.skuQuantity', 'item.cartonCount', 'item.cartonMarks',
]) expect(source).toContain(marker)
expect(source).toContain("SEALED: '已封托'")
})
it('keeps the existing scoped pallet-label action available', () => {
expect(source).toContain('downloadDropshipPalletLabel(props.customerId, props.inboundId, pallet.palletId)')
expect(source).toContain('打印托盘唛')
})
})
@@ -9,6 +9,7 @@ const props = withDefaults(defineProps<{
dropshipPallets?: InboundDropshipPallet[]
customerId?: string
inboundId?: string
canViewDropship?: boolean
files: InboundFileSummary[]
internalNotes: InboundInternalNote[]
statusHistory: InboundStatusHistory[]
@@ -16,6 +17,7 @@ const props = withDefaults(defineProps<{
sections?: Array<'dropship' | 'allocations' | 'files' | 'notes' | 'history' | 'remark'>
}>(), {
dropshipPallets: () => [],
canViewDropship: true,
sections: () => ['dropship', 'allocations', 'files', 'notes', 'history', 'remark'],
})
@@ -30,6 +32,9 @@ function date(value: string | null) {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
}
function dropshipPalletStatus(value: string) {
return ({ OPEN: '清点组托中', SEALED: '已封托' } as Record<string, string>)[value] || value || '状态未知'
}
async function printPallet(pallet: InboundDropshipPallet) {
if (!props.customerId || !props.inboundId || !pallet.palletNo) return
try {
@@ -44,9 +49,35 @@ async function printPallet(pallet: InboundDropshipPallet) {
<template>
<div class="history-panel">
<section v-if="shows('dropship')" class="history-section">
<div class="section-heading"><h3>一件代发绑定托盘</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<div v-if="dropshipPallets.length" class="simple-list"><div v-for="pallet in dropshipPallets" :key="pallet.palletId"><strong>{{ value(pallet.palletNo) }} · {{ pallet.cartonCount }} 箱 <el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button></strong><span v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`">{{ item.inboundCode }} · SKU {{ item.skuCode }} · {{ item.cartonCount }} 箱</span></div></div>
<p v-else class="muted-empty">暂无一件代发托盘绑定</p>
<div class="section-heading"><h3>一件代发清点与组托结果</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<el-alert v-if="!canViewDropship" title="当前账号缺少一件代发 PDA 查看权限,无法读取清点组托结果。" type="warning" :closable="false" show-icon />
<div v-else-if="dropshipPallets.length" class="dropship-pallet-list">
<article v-for="pallet in dropshipPallets" :key="pallet.palletId" class="dropship-pallet-card">
<header>
<div><strong>{{ value(pallet.palletNo) }}</strong><el-tag size="small" :type="pallet.status === 'SEALED' ? 'success' : 'warning'" effect="plain">{{ dropshipPalletStatus(pallet.status) }}</el-tag></div>
<el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button>
</header>
<dl class="dropship-pallet-summary">
<div><dt>箱数</dt><dd>{{ pallet.cartonCount }}</dd></div>
<div><dt>SKU 数</dt><dd>{{ pallet.skuCount }}</dd></div>
<div><dt>毛重</dt><dd>{{ number(pallet.weightKg) }} kg</dd></div>
<div><dt>体积</dt><dd>{{ number(pallet.volumeCbm) }} m³</dd></div>
<div><dt>差异箱数</dt><dd><el-tag size="small" :type="pallet.discrepancyCartons ? 'warning' : 'success'">{{ pallet.discrepancyCartons }}</el-tag></dd></div>
<div><dt>封托时间</dt><dd>{{ date(pallet.sealedAt) }}</dd></div>
</dl>
<div class="dropship-item-table">
<div class="dropship-item-row dropship-item-row--header"><span>入仓码</span><span>SKU / 货物</span><span>商品数量</span><span>箱数</span><span>箱码</span></div>
<div v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`" class="dropship-item-row">
<span>{{ value(item.inboundCode) }}</span>
<span><strong>{{ value(item.skuCode) }}</strong><small>{{ value(item.cargoName) }}</small></span>
<span>{{ item.skuQuantity }}</span>
<span>{{ item.cartonCount }}</span>
<span>{{ item.cartonMarks.length ? item.cartonMarks.join('、') : '—' }}</span>
</div>
</div>
</article>
</div>
<p v-else class="muted-empty">暂无一件代发清点与组托结果</p>
</section>
<section v-if="shows('allocations')" class="history-section">
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
@@ -93,6 +124,25 @@ async function printPallet(pallet: InboundDropshipPallet) {
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
.simple-list > div { padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
.dropship-pallet-list { display: grid; gap: 12px; }
.dropship-pallet-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.dropship-pallet-card > header { display: flex; min-height: 42px; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-bottom: 1px solid #edf0f4; background: #f8fafc; }
.dropship-pallet-card > header > div { display: flex; min-width: 0; align-items: center; gap: 8px; }
.dropship-pallet-card > header strong { overflow-wrap: anywhere; color: #172033; font-size: 13px; }
.dropship-pallet-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 0; border-bottom: 1px solid #edf0f4; }
.dropship-pallet-summary > div { min-width: 0; padding: 8px 12px; border-right: 1px solid #edf0f4; }
.dropship-pallet-summary > div:last-child { border-right: 0; }
.dropship-pallet-summary dt { color: var(--cc-muted); font-size: 11px; }
.dropship-pallet-summary dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 650; }
.dropship-item-table { min-width: 680px; }
.dropship-pallet-card { overflow-x: auto; }
.dropship-item-row { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.2fr) 100px 70px minmax(220px, 1.5fr); min-width: 800px; border-bottom: 1px solid #edf0f4; }
.dropship-item-row:last-child { border-bottom: 0; }
.dropship-item-row > span { min-width: 0; padding: 8px 12px; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; }
.dropship-item-row > span + span { border-left: 1px solid #edf0f4; }
.dropship-item-row small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
.dropship-item-row--header { background: #f8fafc; }
.dropship-item-row--header > span { color: var(--cc-muted); font-size: 11px; font-weight: 650; }
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 6px; background: #f8fafc; color: var(--cc-muted); font-size: 12px; text-align: center; }
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
@@ -101,5 +151,6 @@ async function printPallet(pallet: InboundDropshipPallet) {
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.remark-section p { margin: 0; padding: 12px; border-radius: 6px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
@media (max-width: 720px) { .dropship-pallet-summary { grid-template-columns: 1fr; } .dropship-pallet-summary > div { border-right: 0; border-bottom: 1px solid #edf0f4; } .dropship-pallet-summary > div:last-child { border-bottom: 0; } }
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
</style>
+15 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInboundCartonLabels, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
import { downloadInboundCartonLabels, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInboundDropshipPallets, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
vi.mock('@/shared/api/http', () => ({
ApiResponseError: class ApiResponseError extends Error {
@@ -73,6 +73,20 @@ describe('platform inbound query API', () => {
expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001', { params: { tenantId: '10001' } })
})
it('loads and normalizes the inbound-scoped dropship pallet result', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{
palletId: 9007199254740993n, palletNo: 'PLT-001', status: 'SEALED', cartonCount: '2', skuCount: '1', version: '3',
weightKg: '20.5', volumeCbm: '0.88', discrepancyCartons: '1', sealedAt: '2026-09-08T10:00:00',
items: [{ inboundId: 19001, inboundCode: 'RK001', inboundDetailId: 9007199254740994n, skuCode: 'SKU001', cargoName: '服装', cartonCount: '2', skuQuantity: '19', cartonMarks: ['BOX-001', 'BOX-002'] }],
}] })
await expect(listPlatformInboundDropshipPallets('10001', '19001')).resolves.toMatchObject([{
palletId: '9007199254740993', weightKg: 20.5, volumeCbm: 0.88, discrepancyCartons: 1,
items: [{ inboundId: '19001', inboundDetailId: '9007199254740994', cartonCount: 2, skuQuantity: 19, cartonMarks: ['BOX-001', 'BOX-002'] }],
}])
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/pda-dropship/inbound-orders/19001/pallets', { params: { accountId: '10001' } })
})
it('passes the implemented order, process, measurement and fee review filters without renaming them', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
+33 -5
View File
@@ -11,6 +11,7 @@ import type {
InboundPageResult,
InboundPalletLocation,
InboundStatusHistory,
InboundDropshipPallet,
} from './types'
const basePath = '/platform/inbounds'
@@ -102,11 +103,7 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
return {
...raw,
summary: normalizeInboundSummary(raw.summary),
dropshipPallets: (raw.dropshipPallets || []).map((pallet) => ({
...pallet,
palletId: id(pallet.palletId) || '',
items: (pallet.items || []).map((item) => ({ ...item, inboundId: id(item.inboundId) || '', inboundDetailId: id(item.inboundDetailId) || '' })),
})),
dropshipPallets: [],
palletLocations: (raw.palletLocations || []).map(normalizePallet),
files: (raw.files || []).map(normalizeFile),
internalNotes: (raw.internalNotes || []).map(normalizeNote),
@@ -114,6 +111,29 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
}
}
function normalizeDropshipPallet(raw: InboundDropshipPallet): InboundDropshipPallet {
return {
...raw,
palletId: id(raw.palletId) || '',
palletNo: raw.palletNo || null,
cartonCount: integerValue(raw.cartonCount) || 0,
skuCount: integerValue(raw.skuCount) || 0,
version: integerValue(raw.version) || 0,
weightKg: numberValue(raw.weightKg),
volumeCbm: numberValue(raw.volumeCbm),
discrepancyCartons: integerValue(raw.discrepancyCartons) || 0,
sealedAt: raw.sealedAt || null,
items: (raw.items || []).map((item) => ({
...item,
inboundId: id(item.inboundId) || '',
inboundDetailId: id(item.inboundDetailId) || '',
cartonCount: integerValue(item.cartonCount) || 0,
skuQuantity: integerValue(item.skuQuantity) || 0,
cartonMarks: item.cartonMarks || [],
})),
}
}
function normalizeHoldDetail(raw: InboundHoldDetail): InboundHoldDetail {
return {
...raw,
@@ -161,6 +181,14 @@ export async function getPlatformInbound(tenantId: string, inboundId: string) {
return normalizeDetail(unwrapApiResponse(response))
}
export async function listPlatformInboundDropshipPallets(accountId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundDropshipPallet[]>, ApiEnvelope<InboundDropshipPallet[]>>(
`/platform/v1/first-leg/pda-dropship/inbound-orders/${encodeURIComponent(inboundId)}/pallets`,
{ params: { accountId } },
)
return (unwrapApiResponse(response) || []).map(normalizeDropshipPallet)
}
export async function getPlatformInboundHold(tenantId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundHoldDetail>, ApiEnvelope<InboundHoldDetail>>(
`${basePath}/${encodeURIComponent(inboundId)}/hold`,
+18 -3
View File
@@ -36,6 +36,7 @@ export interface InboundOrderSummary {
deliveryWarehouseId: string | null
deliveryWarehouseName: string | null
businessType: string | null
fulfillmentType: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | string | null
channelCode: string | null
channelName: string | null
taxMode: string | null
@@ -161,12 +162,26 @@ export interface InboundHoldEvidenceUpload {
export interface InboundDropshipPallet {
palletId: string
palletNo: string | null
palletStatus: string
status: 'OPEN' | 'SEALED'
cartonCount: number
skuCount: number
version: number
items: Array<{ inboundId: string; inboundCode: string; inboundDetailId: string; skuCode: string; cargoName: string | null; cartonCount: number; cartonMarks: string[] }>
completedAt: string | null
weightKg: number | null
volumeCbm: number | null
discrepancyCartons: number
sealedAt: string | null
items: InboundDropshipSkuItem[]
}
export interface InboundDropshipSkuItem {
inboundId: string
inboundCode: string
inboundDetailId: string
skuCode: string
cargoName: string | null
cartonCount: number
skuQuantity: number
cartonMarks: string[]
}
export interface InboundPageResult {
@@ -43,7 +43,7 @@ const summary: InboundOrderSummary = {
orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null,
notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户',
companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null,
deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
deliveryWarehouseName: null, businessType: 'FBA', fulfillmentType: 'STANDARD', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8',
holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110,
actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01',
@@ -49,7 +49,7 @@ const detail = {
items: [{
itemId: 11, itemVersion: 4, accountId: 12, orderId: 15, orderNo: 'ORD-001', inboundId: 13, inboundDetailId: 14, containerNo: 'MSCU1234567', productQuantity: 20,
customerName: '测试客户', inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null,
goodsImageUrl: null, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1,
goodsImageUrl: null, unitType: 'SEALED_PALLET', palletId: 9007199254740993n, palletNo: 'PLT-001', indivisible: true, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1,
dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null,
internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null,
}],
@@ -75,7 +75,7 @@ describe('platform loading lists API', () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: detail })
const result = await getPreloadPlan('100', '300')
expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' })
expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', unitType: 'SEALED_PALLET', palletId: '9007199254740993', palletNo: 'PLT-001', indivisible: true, containerNo: 'MSCU1234567', productQuantity: '20' })
expect(result.summary.containerBookingId).toBe('1900000000000000004')
expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operationId: 'transport-21', operatorId: '22' })
})
@@ -91,6 +91,10 @@ export interface LoadingItem {
chineseName: string | null
englishName: string | null
goodsImageUrl: string | null
unitType: 'PACKAGE' | 'SEALED_PALLET'
palletId: string | null
palletNo: string | null
indivisible: boolean
packageCount: number
palletCount: number
grossWeightKg: number
@@ -240,6 +244,9 @@ function normalizeItem(raw: LoadingItem): LoadingItem {
orderId: requiredId(raw.orderId),
inboundId: requiredId(raw.inboundId),
inboundDetailId: requiredId(raw.inboundDetailId),
palletId: optionalId(raw.palletId),
palletNo: raw.palletNo || null,
indivisible: Boolean(raw.indivisible),
productQuantity: String(raw.productQuantity ?? '0'),
}
}
@@ -28,6 +28,8 @@ const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed:
const baseItemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 155, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'unitType', label: '装柜单位', width: 110, slotName: 'unitType' },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '待补录' },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
@@ -212,6 +214,7 @@ function formatNumber(value?: number | null, digits = 3) {
<template #actions="{ row }">
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
</template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag><small v-if="row.indivisible">整托不可拆</small></template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>