Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
5 changed files with 167 additions and 24 deletions
Showing only changes of commit 1f3e14a6cd - Show all commits
@@ -80,7 +80,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).not.toContain('服装')
expect(wrapper.text()).not.toContain('鞋类')
expect(wrapper.text()).not.toContain('SKU-1')
expect(wrapper.text()).not.toContain('IN-1')
expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).toContain('IN-2')
expect(wrapper.text()).not.toContain('ORD-1')
expect(wrapper.text()).not.toContain('ORD-2')
@@ -378,6 +378,34 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).toContain('当前柜次暂无装柜明细')
})
it('shows one row per inbound code and removes all internal specification rows', async () => {
const secondDetail = {
...plan.items[0], id: '102', inboundDetailId: '4', skuCode: 'SKU-2', productNameZh: '鞋类',
packageCount: 4, grossWeightKg: 40, volumeCbm: 0.8, loadSequence: 2,
}
const groupedPlan = { ...plan, items: [plan.items[0], secondDetail] }
const afterFirst = { ...plan, version: 3, items: [secondDetail] }
const emptyPlan = { ...plan, version: 4, items: [] }
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(groupedPlan).mockResolvedValue(emptyPlan)
vi.mocked(removeSmartLoadingItem).mockResolvedValueOnce(afterFirst).mockResolvedValueOnce(emptyPlan)
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const inboundRows = wrapper.findAll('tr').filter((row) => row.text().includes('IN-1'))
expect(inboundRows).toHaveLength(1)
expect(inboundRows[0].text()).toContain('多规格;改箱数请移除后重加')
expect(inboundRows[0].text()).toContain('100 kg')
expect(inboundRows[0].text()).toContain('2 m³')
await inboundRows[0].find('.el-button--danger').trigger('click')
await flushPromises()
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1)
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondDetail.id, 3, 1)
})
it('uses item capabilities to protect picked quantities and removal', async () => {
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
@@ -26,9 +26,9 @@ import {
import type {
ContainerBookingResource,
SmartLoadingCandidate,
SmartLoadingItem,
SmartLoadingPlan,
} from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -79,6 +79,7 @@ const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || []))
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
@@ -127,6 +128,7 @@ const candidateColumns: DataTableColumn[] = [
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
@@ -138,7 +140,7 @@ function formatNumber(value: number, digits = 3) {
}
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function itemRow(source: object) { return source as SmartLoadingInboundItem }
function adjustmentMax(source: object) {
const item = itemRow(source)
const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0
@@ -159,7 +161,7 @@ function displayActualContainerNo(container: ContainerBookingResource | null | u
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
for (const item of next.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
for (const item of groupSmartLoadingItems(next.items || [])) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
plan.value = next
@@ -328,34 +330,42 @@ async function adjust(source: object) {
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.customerName || '当前客户'}”的 ${item.packageCount} 箱明细吗?`, '移除装柜明细', {
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
let current = plan.value!
for (const original of item.sourceItems) {
const currentItem = current.items.find((value) => value.id === original.id)
if (!currentItem) continue
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
acceptPlan(current)
}
await loadSelectedData()
ElMessage.success('装柜明细已移除')
ElMessage.success('该入仓码已从当前柜次移除')
}, '移除装柜明细失败', { pickingConflict: true })
}
async function removeAll() {
const removableItems = plan.value?.items.filter((item) => item.canRemove) || []
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
if (!removableItems.length) return
const itemCount = removableItems.length
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 条未拣明细吗?已拣明细会保留。`, '移除未拣明细', {
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
let current = plan.value!
for (const original of removableItems) {
const item = current.items.find((value) => value.id === original.id)
if (!item) continue
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
acceptPlan(current)
for (const inboundItem of removableItems) {
for (const original of inboundItem.sourceItems) {
const item = current.items.find((value) => value.id === original.id)
if (!item) continue
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
acceptPlan(current)
}
}
await loadSelectedData()
ElMessage.success(`已移除 ${itemCount} 条未拣明细`)
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
}, '全部移除失败', { pickingConflict: true })
}
async function pushPreload() {
@@ -528,8 +538,8 @@ onMounted(() => { void load(); void loadCustomers() })
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="plan.items" :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="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<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="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
@@ -15,7 +15,8 @@ import {
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
recommendSmartLoading, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingItem, SmartLoadingPlan } from '../types'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
const route = useRoute()
const router = useRouter()
@@ -42,6 +43,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 displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || []))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1)
@@ -75,7 +77,7 @@ const candidateColumns: DataTableColumn[] = [
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function itemRow(source: object) { return source as SmartLoadingInboundItem }
function explainCandidate(source: object) {
const candidate = candidateRow(source)
const volume = Number(candidate.averageUnitVolumeCbm || 0)
@@ -95,7 +97,7 @@ function displayContainerNo(value: ContainerBookingResource | null | undefined)
}
function syncDrafts(preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
for (const item of plan.value?.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
@@ -178,11 +180,21 @@ async function adjust(source: object) {
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
await ElMessageBox.confirm(`确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await removeSmartLoadingItem(plan.value.id, item.id, plan.value.version, item.version)); ElMessage.success('装柜明细已移除') }
try {
let current = plan.value!
for (const original of item.sourceItems) {
const currentItem = current.items.find((value) => value.id === original.id)
if (!currentItem) continue
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
acceptPlan(current)
}
await load()
ElMessage.success('该入仓码已从装柜方案移除')
}
catch (error) { await handleMutationError(error, '移除失败', true) }
finally { mutating.value = false }
})
@@ -251,9 +263,9 @@ onMounted(load)
<template v-else-if="plan">
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import type { SmartLoadingItem } from './types'
import { groupSmartLoadingItems } from './smartLoadingItems'
function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
return {
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,
grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 2, version: 1,
pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
...overrides,
}
}
describe('groupSmartLoadingItems', () => {
it('shows multiple specification rows as one inbound-code row with summed quantities', () => {
const grouped = groupSmartLoadingItems([
item(),
item({ id: '2', inboundDetailId: '41', skuCode: 'SKU-2', productNameZh: '货物二', packageCount: 2, grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 3 }),
])
expect(grouped).toHaveLength(1)
expect(grouped[0]).toMatchObject({
inboundCode: 'NYEXX2608180003', packageCount: 4, grossWeightKg: 1325.6,
volumeCbm: 0.668, detailCount: 2, canAdjust: false, canRemove: true,
skuCode: 'SKU-1、SKU-2', productNameZh: '货物一、货物二', loadSequence: 2,
})
expect(grouped[0].sourceItems.map((source) => source.id)).toEqual(['1', '2'])
})
it('does not merge different inbound codes and keeps single-detail adjustment', () => {
const grouped = groupSmartLoadingItems([
item(),
item({ id: '2', inboundId: '31', inboundCode: 'NYEXX2608180004' }),
])
expect(grouped).toHaveLength(2)
expect(grouped.every((row) => row.detailCount === 1 && row.canAdjust)).toBe(true)
})
})
@@ -0,0 +1,51 @@
import type { SmartLoadingItem } from './types'
export interface SmartLoadingInboundItem extends SmartLoadingItem {
sourceItems: SmartLoadingItem[]
detailCount: number
}
function sumNullable(items: SmartLoadingItem[], value: (item: SmartLoadingItem) => number | null) {
const values = items.map(value).filter((item): item is number => item != null)
return values.length ? values.reduce((sum, item) => sum + Number(item || 0), 0) : null
}
function joinDistinct(items: SmartLoadingItem[], value: (item: SmartLoadingItem) => string | null) {
const values = [...new Set(items.map(value).map((item) => String(item || '').trim()).filter(Boolean))]
return values.length ? values.join('、') : null
}
/**
* 后端按规格保存装柜明细以保证重量、体积计算准确;作业页面按入仓码汇总展示。
*/
export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingInboundItem[] {
const groups = new Map<string, SmartLoadingItem[]>()
for (const item of items) {
const key = `${item.accountId}:${item.inboundId}`
groups.set(key, [...(groups.get(key) || []), item])
}
return [...groups.values()].map((sourceItems) => {
const first = sourceItems[0]
const pickedPackageCount = sourceItems.reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0)
const loadSequences = sourceItems.map((item) => item.loadSequence).filter((item): item is number => item != null)
return {
...first,
id: sourceItems.length === 1 ? first.id : `inbound:${first.accountId}:${first.inboundId}`,
inboundDetailId: sourceItems.length === 1 ? first.inboundDetailId : '',
skuCode: joinDistinct(sourceItems, (item) => item.skuCode),
productNameZh: joinDistinct(sourceItems, (item) => item.productNameZh),
packageCount: sourceItems.reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
productQuantity: sumNullable(sourceItems, (item) => item.productQuantity),
grossWeightKg: sumNullable(sourceItems, (item) => item.grossWeightKg),
volumeCbm: sumNullable(sourceItems, (item) => item.volumeCbm),
loadSequence: loadSequences.length ? Math.min(...loadSequences) : null,
pickedPackageCount,
minPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.minPackageCount || 0), 0),
canAdjust: sourceItems.length === 1 && first.canAdjust,
canRemove: sourceItems.every((item) => item.canRemove),
sourceItems,
detailCount: sourceItems.length,
}
})
}