优化
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import ElementPlus, { ElInputNumber, ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { AxiosError } from 'axios'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
@@ -71,10 +71,28 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
|
||||
expect(wrapper.text()).toContain('候选入库单与货物')
|
||||
expect(wrapper.text()).toContain('美森正班海卡包税')
|
||||
expect(wrapper.text()).toContain('ORD-2')
|
||||
expect(wrapper.text()).toContain('CB-1 装柜方案')
|
||||
expect(wrapper.text()).toContain('LP-99')
|
||||
expect(wrapper.text()).toContain('ORD-1')
|
||||
expect(wrapper.text()).toContain('IN-2')
|
||||
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
|
||||
expect(wrapper.text()).not.toContain('LP-99')
|
||||
expect(wrapper.text()).toContain('IN-1')
|
||||
expect(wrapper.text()).not.toContain('ORD-1')
|
||||
expect(wrapper.text()).not.toContain('ORD-2')
|
||||
})
|
||||
|
||||
it('shows one user-facing container number and hides internal manual or split references', async () => {
|
||||
vi.mocked(listContainers).mockResolvedValue({
|
||||
records: [{ ...container, cabinetNo: 'CAB-20260814-01', containerNo: 'SPLIT-814022117898chain1' }],
|
||||
page: 1,
|
||||
size: 100,
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('CAB-20260814-01')
|
||||
expect(wrapper.text()).not.toContain('SPLIT-814022117898chain1')
|
||||
expect(wrapper.text()).not.toContain('LP-99')
|
||||
})
|
||||
|
||||
it('groups containers by channel and reloads candidates when the channel changes', async () => {
|
||||
@@ -83,6 +101,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
id: '9007199254740998',
|
||||
planId: '9007199254740999',
|
||||
cabinetNo: 'CB-2',
|
||||
containerNo: 'MSCU-2',
|
||||
channelCode: 'HDHK',
|
||||
channelName: '合德海卡包税',
|
||||
}
|
||||
@@ -92,8 +111,9 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.container-option')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('CB-1')
|
||||
expect(wrapper.text()).not.toContain('CB-2')
|
||||
expect(wrapper.text()).toContain('MSCU-1')
|
||||
expect(wrapper.text()).not.toContain('MSCU-2')
|
||||
expect(wrapper.text()).not.toContain('个柜次')
|
||||
|
||||
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('合德海卡包税'))
|
||||
expect(tab).toBeDefined()
|
||||
@@ -101,8 +121,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.container-option')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('CB-2')
|
||||
expect(wrapper.text()).not.toContain('CB-1')
|
||||
expect(wrapper.text()).toContain('MSCU-2')
|
||||
expect(wrapper.text()).not.toContain('MSCU-1')
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId)
|
||||
})
|
||||
|
||||
@@ -129,16 +149,14 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps unsupported prototype filters out of the real workbench', async () => {
|
||||
it('keeps only customer, inbound code and destination warehouse filters', async () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const statusField = wrapper.findComponent(QueryToolbar).props('fields')
|
||||
.find((field: { key: string }) => field.key === 'status')
|
||||
expect(statusField?.options).toEqual([
|
||||
{ label: '待装柜', value: 'WAIT_LOADING' },
|
||||
{ label: '预装柜中', value: 'EDITING' },
|
||||
{ label: '拣货中', value: 'LOADING' },
|
||||
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>
|
||||
expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode', 'destinationWarehouse'])
|
||||
expect(fields.find((field) => field.key === 'destinationWarehouse')?.options).toEqual([
|
||||
{ label: 'LAX9', value: '9' },
|
||||
])
|
||||
|
||||
expect(wrapper.text()).not.toContain('税率')
|
||||
@@ -152,11 +170,11 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(wrapper.text()).toContain('CB-1 装柜方案')
|
||||
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
|
||||
})
|
||||
|
||||
it('does not overwrite the container status when selecting it', async () => {
|
||||
const waiting = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-WAIT', planStatus: 'WAIT_LOADING' as const }
|
||||
it('keeps waiting and editing empty containers without exposing their status labels', async () => {
|
||||
const waiting = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-WAIT', containerNo: null, planStatus: 'WAIT_LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [container, waiting], page: 1, size: 100, total: 2, pages: 1 })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => (
|
||||
bookingId === waiting.id
|
||||
@@ -167,31 +185,27 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await flushPromises()
|
||||
|
||||
const waitingOption = wrapper.findAll('.container-option').find((item) => item.text().includes('CB-WAIT'))!
|
||||
expect(waitingOption.text()).toContain('待装柜')
|
||||
expect(waitingOption.text()).toContain('CB-WAIT')
|
||||
expect(waitingOption.text()).not.toContain('待装柜')
|
||||
|
||||
await waitingOption.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(waiting.id)
|
||||
expect(waitingOption.text()).toContain('待装柜')
|
||||
expect(waitingOption.text()).toContain('CB-WAIT')
|
||||
expect(waitingOption.text()).not.toContain('拣货中')
|
||||
})
|
||||
|
||||
it('keeps picking containers editable while hiding the push action', async () => {
|
||||
it('excludes picking containers from the empty-container workbench', async () => {
|
||||
const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const }
|
||||
const pickingPlan = { ...plan, bookingId: pushed.id, status: 'LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(pickingPlan)
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(pushed.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.id, pushed.warehouseId)
|
||||
expect(wrapper.text()).toContain('CB-2')
|
||||
expect(wrapper.text()).toContain('拣货中')
|
||||
expect(wrapper.text()).toContain('智能推荐')
|
||||
expect(wrapper.text()).toContain('加入')
|
||||
expect(wrapper.findAll('button').some((item) => item.text().trim() === '推送预装')).toBe(false)
|
||||
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
|
||||
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.container-option')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('CB-2')
|
||||
})
|
||||
|
||||
it('hides completed containers from the smart loading workbench', async () => {
|
||||
@@ -206,6 +220,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
})
|
||||
|
||||
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
|
||||
const warningMessage = vi.spyOn(ElMessage, 'warning').mockImplementation(() => undefined as never)
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, version: 3 })
|
||||
@@ -224,16 +239,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('版本 3')
|
||||
expect(wrapper.text()).toContain('已刷新最新版本')
|
||||
expect(wrapper.text()).toContain('request-1')
|
||||
expect(warningMessage).toHaveBeenCalledWith('方案已被其他人修改,已刷新最新版本,请重新确认后提交')
|
||||
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||||
expect(wrapper.text()).not.toContain('request-1')
|
||||
})
|
||||
|
||||
it('shows a picking-specific message when a picked order cannot be removed', async () => {
|
||||
const loadingContainer = { ...container, planStatus: 'LOADING' as const }
|
||||
const loadingPlan = { ...plan, status: 'LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [loadingContainer], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(loadingPlan)
|
||||
vi.mocked(removeSmartLoadingItem).mockRejectedValue(new AxiosError(
|
||||
'conflict',
|
||||
'ERR_BAD_REQUEST',
|
||||
@@ -253,15 +264,80 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
expect(removeSmartLoadingItem).toHaveBeenCalled()
|
||||
expect(errorMessage).toHaveBeenCalledWith('该订单在拣货中不许删除')
|
||||
expect(wrapper.text()).toContain('该订单在拣货中不许删除')
|
||||
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||||
expect(wrapper.text()).not.toContain('该订单在拣货中不许删除')
|
||||
})
|
||||
|
||||
it('removes every item with the latest returned plan version', async () => {
|
||||
const secondItem = { ...plan.items[0], id: '102', inboundDetailId: '4', inboundCode: 'IN-2' }
|
||||
const initialPlan = { ...plan, items: [plan.items[0], secondItem] }
|
||||
const afterFirst = { ...plan, version: 3, items: [secondItem] }
|
||||
const emptyPlan = { ...plan, version: 4, items: [] }
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(initialPlan).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 removeAllButton = wrapper.findAll('button').find((button) => button.text().includes('全部移除'))
|
||||
expect(removeAllButton).toBeDefined()
|
||||
await removeAllButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1)
|
||||
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondItem.id, 3, 1)
|
||||
expect(wrapper.text()).toContain('当前柜次暂无已装货物')
|
||||
})
|
||||
|
||||
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 4, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const maxima = wrapper.findAllComponents(ElInputNumber).map((input) => input.props('max'))
|
||||
expect(maxima).toContain(10)
|
||||
})
|
||||
|
||||
it('uses bordered assignment and adjustment actions at narrow widths', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain('size="small"')
|
||||
expect(source).toContain('type="danger" plain')
|
||||
expect(source).toContain(':max="adjustmentMax(row)"')
|
||||
expect(source).not.toContain('link type="danger"')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source.match(/<DataTable[\s\S]*?\bstripe\b/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('bounds all three workbench panes and keeps their content independently scrollable', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain('height: clamp(520px, 68vh, 720px); min-height: 0;')
|
||||
expect(source).toContain('.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }')
|
||||
expect(source).toContain('.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }')
|
||||
expect(source).toContain('.container-list { display: grid; min-height: 0; flex: 1;')
|
||||
expect(source).toContain('grid-auto-rows: max-content;')
|
||||
expect(source).toContain('overflow-wrap: anywhere;')
|
||||
expect(source).toContain('.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }')
|
||||
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
|
||||
})
|
||||
|
||||
it('uses overflow-aware channel tabs with a compact right-aligned summary', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain('<el-tabs :model-value="activeChannelCode"')
|
||||
expect(source).toContain('<el-tab-pane v-for="channel in channelTabs"')
|
||||
expect(source).toContain(':label="channel.name"')
|
||||
expect(source).not.toContain('channel.containerCount')
|
||||
expect(source).toContain('class="compact-summary" aria-label="当前渠道装柜统计"')
|
||||
expect(source).toContain('<dt>候选货物</dt>')
|
||||
expect(source).toContain('<dt>空柜</dt>')
|
||||
expect(source).toContain('<dt>已装</dt>')
|
||||
expect(source).toContain('<dt>总重量</dt>')
|
||||
expect(source).toContain('<dt>总体积</dt>')
|
||||
expect(source).toContain('.channel-tabs :deep(.el-tabs__content) { display: none; }')
|
||||
expect(source).not.toContain('class="summary-band"')
|
||||
expect(source).toContain('class="capacity-grid"')
|
||||
expect(source).toContain('智能推荐')
|
||||
expect(source).toContain('推送预装')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,13 +46,12 @@ const selectionLoading = ref(false)
|
||||
const mutating = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const selectionError = ref<ApiErrorDetails | null>(null)
|
||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const noDraft = ref(false)
|
||||
const assignOpen = ref(false)
|
||||
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
const quantityDrafts = reactive<Record<string, number>>({})
|
||||
const query = ref<QueryModel>({ customerId: '', orderKeyword: '', containerKeyword: '', status: '' })
|
||||
const query = ref<QueryModel>({ customerId: '', inboundCode: '', destinationWarehouse: '' })
|
||||
const appliedQuery = ref<QueryModel>({ ...query.value })
|
||||
let selectionRequest = 0
|
||||
|
||||
@@ -62,15 +61,12 @@ const canDelete = computed(() => access.can('platform:firstleg:smart-loading:del
|
||||
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
|
||||
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
|
||||
const channelTabs = computed(() => {
|
||||
const channels = new Map<string, { code: string; name: string; containerCount: number }>()
|
||||
const channels = new Map<string, { code: string; name: string }>()
|
||||
for (const item of containers.value) {
|
||||
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus) || !item.channelCode) continue
|
||||
const current = channels.get(item.channelCode)
|
||||
if (current) current.containerCount += 1
|
||||
else channels.set(item.channelCode, {
|
||||
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
|
||||
code: item.channelCode,
|
||||
name: item.channelName || item.channelCode,
|
||||
containerCount: 1,
|
||||
})
|
||||
}
|
||||
return [...channels.values()]
|
||||
@@ -88,49 +84,45 @@ const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / vol
|
||||
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
|
||||
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
|
||||
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
||||
|
||||
const destinationWarehouseOptions = computed(() => {
|
||||
const options = new Map<string, string>()
|
||||
for (const item of candidates.value) {
|
||||
const value = destinationWarehouseKey(item)
|
||||
if (value) options.set(value, item.destinationWarehouseName || item.destinationWarehouseCode || value)
|
||||
}
|
||||
return [...options].map(([value, label]) => ({ label, value }))
|
||||
})
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、货物', width: 250 },
|
||||
{ key: 'containerKeyword', type: 'input', label: '柜号', placeholder: '请输入柜号', width: 230 },
|
||||
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
|
||||
{ label: '待装柜', value: 'WAIT_LOADING' },
|
||||
{ label: '预装柜中', value: 'EDITING' },
|
||||
] },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 },
|
||||
{ key: 'destinationWarehouse', type: 'select', label: '目的仓', placeholder: '全部目的仓', width: 200, options: destinationWarehouseOptions.value, props: { filterable: true } },
|
||||
])
|
||||
|
||||
const filteredContainers = computed(() => {
|
||||
const keyword = String(appliedQuery.value.containerKeyword || '').trim().toLowerCase()
|
||||
const status = String(appliedQuery.value.status || '')
|
||||
return containers.value.filter((item) => {
|
||||
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus)) return false
|
||||
if (!activeChannelCode.value || item.channelCode !== activeChannelCode.value) return false
|
||||
if (status && item.planStatus !== status) return false
|
||||
if (!keyword) return true
|
||||
return displayContainerNo(item).toLowerCase().includes(keyword)
|
||||
})
|
||||
})
|
||||
const filteredContainers = computed(() => containers.value.filter((item) => (
|
||||
['WAIT_LOADING', 'EDITING'].includes(item.planStatus)
|
||||
&& Boolean(activeChannelCode.value)
|
||||
&& item.channelCode === activeChannelCode.value
|
||||
)))
|
||||
const filteredCandidates = computed(() => {
|
||||
const customerId = String(appliedQuery.value.customerId || '')
|
||||
const keyword = String(appliedQuery.value.orderKeyword || '').trim().toLowerCase()
|
||||
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
|
||||
const destinationWarehouse = String(appliedQuery.value.destinationWarehouse || '')
|
||||
return candidates.value.filter((item) => {
|
||||
if (customerId && item.accountId !== customerId) return false
|
||||
if (!keyword) return true
|
||||
return [item.orderNo, item.inboundCode, item.goodsName]
|
||||
.some((value) => String(value || '').toLowerCase().includes(keyword))
|
||||
if (destinationWarehouse && destinationWarehouseKey(item) !== destinationWarehouse) return false
|
||||
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
|
||||
})
|
||||
})
|
||||
|
||||
function preferredContainerId() {
|
||||
const visible = filteredContainers.value
|
||||
if (String(appliedQuery.value.status || '').trim()) return visible[0]?.id || ''
|
||||
return visible.find((item) => item.planStatus === 'EDITING')?.id
|
||||
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
|
||||
}
|
||||
|
||||
const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
|
||||
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
|
||||
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
|
||||
@@ -139,7 +131,7 @@ const candidateColumns: DataTableColumn[] = [
|
||||
]
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
|
||||
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
|
||||
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
|
||||
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
||||
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
|
||||
@@ -152,6 +144,14 @@ 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 adjustmentMax(source: object) {
|
||||
const item = itemRow(source)
|
||||
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
|
||||
return Math.max(1, item.packageCount + remaining)
|
||||
}
|
||||
function destinationWarehouseKey(candidate: SmartLoadingCandidate) {
|
||||
return candidate.destinationWarehouseId || candidate.destinationWarehouseCode || candidate.destinationWarehouseName || ''
|
||||
}
|
||||
function displayContainerNo(container: ContainerBookingResource | null | undefined) {
|
||||
if (!container) return '柜号待生成'
|
||||
const actual = String(container.containerNo || '').trim()
|
||||
@@ -250,14 +250,12 @@ async function loadCustomers() {
|
||||
async function selectContainer(containerId: string) {
|
||||
if (selectedContainerId.value === containerId) return
|
||||
selectedContainerId.value = containerId
|
||||
mutationError.value = null
|
||||
await loadSelectedData()
|
||||
}
|
||||
async function selectChannel(channelCode: string) {
|
||||
if (activeChannelCode.value === channelCode) return
|
||||
activeChannelCode.value = channelCode
|
||||
selectedContainerId.value = preferredContainerId()
|
||||
mutationError.value = null
|
||||
await loadSelectedData()
|
||||
}
|
||||
async function applyFilters(model: QueryModel) {
|
||||
@@ -281,6 +279,10 @@ function openAssign(source: object) {
|
||||
}
|
||||
async function assign() {
|
||||
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
||||
if (assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
|
||||
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
|
||||
return
|
||||
}
|
||||
await mutate(async () => {
|
||||
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
|
||||
inboundId: selectedCandidate.value!.inboundId,
|
||||
@@ -289,6 +291,7 @@ async function assign() {
|
||||
expectedVersion: plan.value!.version,
|
||||
}))
|
||||
assignOpen.value = false
|
||||
await loadSelectedData()
|
||||
ElMessage.success('货物已加入当前柜次')
|
||||
}, '分配货物失败', { preserveAssign: true })
|
||||
}
|
||||
@@ -296,6 +299,7 @@ async function recommend() {
|
||||
if (!plan.value) return
|
||||
await mutate(async () => {
|
||||
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
|
||||
await loadSelectedData()
|
||||
ElMessage.success('智能推荐已生成')
|
||||
}, '智能推荐失败')
|
||||
}
|
||||
@@ -304,23 +308,46 @@ async function adjust(source: object) {
|
||||
if (!plan.value) return
|
||||
const count = Number(quantityDrafts[item.id])
|
||||
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
|
||||
const max = adjustmentMax(item)
|
||||
if (count > max) { ElMessage.warning(`该货物最多可分配 ${max} 箱`); return }
|
||||
await mutate(async () => {
|
||||
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
|
||||
await loadSelectedData()
|
||||
ElMessage.success('箱数已调整')
|
||||
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
|
||||
}
|
||||
async function remove(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value) return
|
||||
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.orderNo || item.id}”吗?`, '移除装柜明细', {
|
||||
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', {
|
||||
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))
|
||||
await loadSelectedData()
|
||||
ElMessage.success('装柜明细已移除')
|
||||
}, '移除装柜明细失败', { pickingConflict: true })
|
||||
}
|
||||
async function removeAll() {
|
||||
if (!plan.value?.items.length) return
|
||||
const itemCount = plan.value.items.length
|
||||
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 [...current.items]) {
|
||||
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} 条装柜明细`)
|
||||
}, '全部移除失败', { pickingConflict: true })
|
||||
}
|
||||
async function pushPreload() {
|
||||
if (!plan.value) return
|
||||
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
|
||||
@@ -339,7 +366,6 @@ async function mutate(
|
||||
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
|
||||
) {
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
@@ -349,12 +375,11 @@ async function mutate(
|
||||
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
|
||||
? '该订单在拣货中不许删除'
|
||||
: null
|
||||
mutationError.value = { ...details, message: pickingMessage || '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
|
||||
const message = pickingMessage || '方案已被其他人修改,已刷新最新版本,请重新确认后提交'
|
||||
if (recovery.preserveAssign) assignOpen.value = true
|
||||
if (pickingMessage) ElMessage.error(pickingMessage)
|
||||
else ElMessage.warning(mutationError.value.message)
|
||||
else ElMessage.warning(message)
|
||||
} else {
|
||||
mutationError.value = details
|
||||
ElMessage.error(details.message)
|
||||
}
|
||||
} finally {
|
||||
@@ -368,26 +393,24 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<template>
|
||||
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
|
||||
<section class="global-loading-page">
|
||||
<nav v-if="channelTabs.length" class="channel-tabs" aria-label="物流渠道" role="tablist">
|
||||
<button
|
||||
v-for="channel in channelTabs"
|
||||
:key="channel.code"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="channel.code === activeChannelCode"
|
||||
:class="{ active: channel.code === activeChannelCode }"
|
||||
@click="selectChannel(channel.code)"
|
||||
>
|
||||
<span>{{ channel.name }}</span>
|
||||
<small>{{ channel.containerCount }} 个柜次</small>
|
||||
</button>
|
||||
</nav>
|
||||
<div v-if="channelTabs.length" class="channel-summary-row">
|
||||
<el-tabs :model-value="activeChannelCode" class="channel-tabs" aria-label="物流渠道" @tab-change="selectChannel(String($event))">
|
||||
<el-tab-pane v-for="channel in channelTabs" :key="channel.code" :label="channel.name" :name="channel.code" />
|
||||
</el-tabs>
|
||||
<dl class="compact-summary" aria-label="当前渠道装柜统计">
|
||||
<div><dt>候选货物</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
|
||||
<div><dt>空柜</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
|
||||
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
|
||||
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
|
||||
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<el-alert v-else-if="containers.length" type="warning" :closable="false" show-icon title="当前柜次缺少渠道快照,暂时不能参与智能装柜" />
|
||||
<QueryToolbar
|
||||
v-model="query"
|
||||
ariaLabel="智能装柜筛选"
|
||||
:fields="queryFields"
|
||||
preference-key="global-smart-loading-query"
|
||||
preference-key="global-smart-loading-query-v2"
|
||||
:search-on-reset="false"
|
||||
@search="applyFilters"
|
||||
/>
|
||||
@@ -396,18 +419,6 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
<template v-else>
|
||||
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新当前柜次" @retry="loadSelectedData()">
|
||||
<template #details><ApiErrorMeta :details="mutationError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<section class="summary-band" aria-label="当前装柜汇总">
|
||||
<div><span>候选货物</span><strong>{{ filteredCandidates.length }}</strong></div>
|
||||
<div><span>可见空柜</span><strong>{{ filteredContainers.length }}</strong></div>
|
||||
<div><span>当前已装箱数</span><strong>{{ totalPackages }}</strong></div>
|
||||
<div><span>当前总体积</span><strong>{{ formatNumber(totalVolume) }} m³</strong></div>
|
||||
<div><span>当前总重量</span><strong>{{ formatNumber(totalWeight, 1) }} kg</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="dispatch-grid" aria-label="智能装柜调度台">
|
||||
<section class="dispatch-pane candidate-pane">
|
||||
<header class="pane-header"><div><strong>候选入库单与货物</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
|
||||
@@ -430,7 +441,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
@click="openAssign(row)"
|
||||
><Plus :size="15" />加入</el-button>
|
||||
</template>
|
||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
|
||||
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
|
||||
</DataTable>
|
||||
@@ -464,6 +475,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<div class="plan-actions">
|
||||
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
<el-button v-if="canDelete && editable && plan?.items.length" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />全部移除</el-button>
|
||||
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -488,9 +500,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</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" 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" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></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="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||
</DataTable>
|
||||
@@ -501,7 +513,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
|
||||
<div v-if="selectedCandidate" class="assign-dialog">
|
||||
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
|
||||
<p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
|
||||
<p>{{ selectedCandidate.inboundCode || '入仓码暂缺' }} · 可用 {{ 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" /></el-form-item></el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
@@ -513,30 +525,33 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<style scoped>
|
||||
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
|
||||
.embedded-smart-loading { min-width: 0; }
|
||||
.channel-tabs { display: flex; min-height: 48px; overflow-x: auto; border-bottom: 2px solid var(--cc-border); background: #fff; }
|
||||
.channel-tabs button { display: flex; min-width: 150px; min-height: 48px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 7px; padding: 8px 18px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--cc-text); cursor: pointer; }
|
||||
.channel-tabs button > span { overflow: hidden; max-width: 220px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.channel-tabs button.active { margin-bottom: -2px; border-bottom-color: var(--el-color-primary); color: var(--el-color-primary); font-weight: 600; }
|
||||
.channel-tabs button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -3px; }
|
||||
.channel-tabs small { color: var(--cc-muted); font-weight: 400; white-space: nowrap; }
|
||||
.summary-band { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||
.summary-band > div { display: grid; gap: 4px; padding: 12px 14px; background: #fff; }
|
||||
.summary-band span,.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
|
||||
.summary-band strong { font-size: 16px; }
|
||||
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); min-height: 620px; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.channel-summary-row { display: grid; grid-template-columns: minmax(240px, 1fr) auto; min-width: 0; align-items: end; border-bottom: 2px solid var(--cc-border); background: #fff; }
|
||||
.channel-tabs { min-width: 0; }
|
||||
.channel-tabs :deep(.el-tabs__header) { margin: 0 0 -2px; }
|
||||
.channel-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
|
||||
.channel-tabs :deep(.el-tabs__item) { overflow: hidden; max-width: 240px; height: 48px; padding-inline: 18px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.channel-tabs :deep(.el-tabs__content) { display: none; }
|
||||
.compact-summary { display: flex; min-height: 48px; align-items: center; justify-content: flex-end; gap: 16px; margin: 0; padding: 6px 12px; white-space: nowrap; }
|
||||
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
|
||||
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
|
||||
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
|
||||
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
|
||||
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
|
||||
.dispatch-pane { display: flex; min-width: 0; flex-direction: column; }
|
||||
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
|
||||
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
|
||||
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
|
||||
.pane-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
||||
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }
|
||||
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
|
||||
.container-pane { background: #fafafa; }
|
||||
.container-list { display: grid; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
|
||||
.container-option { display: grid; width: 100%; min-height: 118px; gap: 5px; padding: 10px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); text-align: left; cursor: pointer; }
|
||||
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
|
||||
.container-option { display: grid; width: 100%; min-height: 0; height: auto; align-content: start; gap: 5px; padding: 10px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.35; text-align: left; cursor: pointer; }
|
||||
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
|
||||
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
|
||||
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
|
||||
.plan-header { flex-wrap: wrap; }
|
||||
.plan-actions,.row-actions,.meter-title { display: flex; align-items: center; gap: 8px; }
|
||||
.plan-actions,.row-actions { flex-wrap: wrap; }
|
||||
@@ -550,18 +565,20 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
@media (max-width: 1180px) {
|
||||
.dispatch-grid { grid-template-columns: 1fr; overflow: visible; }
|
||||
.dispatch-grid { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
|
||||
.embedded-smart-loading .dispatch-grid { height: auto; }
|
||||
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
|
||||
.candidate-pane,.plan-pane { height: min(70vh, 600px); }
|
||||
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: 360px; }
|
||||
.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }
|
||||
.container-pane { max-height: min(58vh, 460px); }
|
||||
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: none; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.summary-band,.capacity-grid { grid-template-columns: 1fr; }
|
||||
.channel-summary-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
|
||||
.compact-summary { min-height: 0; justify-content: flex-start; flex-wrap: wrap; gap: 6px 14px; padding: 8px 12px; white-space: normal; }
|
||||
.capacity-grid { grid-template-columns: 1fr; }
|
||||
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
|
||||
.plan-actions :deep(.el-button) { width: 100%; min-height: 40px; margin-left: 0; }
|
||||
.container-list { grid-template-columns: 1fr; }
|
||||
.channel-tabs button { min-width: 156px; align-items: flex-start; flex-direction: column; gap: 2px; padding-inline: 12px; text-align: left; }
|
||||
.channel-tabs button > span { max-width: 132px; }
|
||||
.channel-tabs :deep(.el-tabs__item) { max-width: 180px; padding-inline: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,12 +43,14 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(getContainer).toHaveBeenCalledWith(container.id)
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, '8')
|
||||
expect(wrapper.text()).toContain('LP-99')
|
||||
expect(wrapper.text()).toContain('ORD-1')
|
||||
expect(wrapper.text()).toContain('按 110% 上限')
|
||||
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
|
||||
expect(wrapper.text()).not.toContain('LP-99')
|
||||
expect(wrapper.text()).toContain('IN-1')
|
||||
expect(wrapper.text()).not.toContain('ORD-1')
|
||||
expect(wrapper.text()).toContain('110% 容量上限')
|
||||
})
|
||||
|
||||
it('keeps picking containers editable while hiding the push action', async () => {
|
||||
it('keeps picking containers out of intelligent loading and links to the preload list', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'LOADING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||
@@ -56,12 +58,12 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
|
||||
expect(wrapper.text()).toContain('拣货中')
|
||||
expect(wrapper.text()).toContain('智能推荐')
|
||||
expect(wrapper.text()).toContain('ORD-1')
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('推送预装'))).toBe(false)
|
||||
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
|
||||
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('该柜号已进入拣货流程')
|
||||
expect(wrapper.text()).toContain('查看预装清单')
|
||||
expect(wrapper.text()).not.toContain('智能推荐')
|
||||
expect(wrapper.text()).not.toContain('ORD-1')
|
||||
})
|
||||
|
||||
it('refreshes the latest plan after A0409', async () => {
|
||||
@@ -80,7 +82,6 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('智能推荐'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('版本 3')
|
||||
expect(wrapper.text()).toContain('已刷新最新版本')
|
||||
expect(wrapper.text()).toContain('request-1')
|
||||
})
|
||||
@@ -92,6 +93,8 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(source).toContain("canPush && plan?.status === 'EDITING'")
|
||||
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
|
||||
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
|
||||
expect(source).not.toContain("label: '目的仓'")
|
||||
expect(source).not.toContain('plan.planNo')
|
||||
expect(source.match(/<DataTable[^>]*\bstripe\b/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
|
||||
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
|
||||
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
|
||||
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
|
||||
@@ -62,7 +62,7 @@ const itemColumns: DataTableColumn[] = [
|
||||
]
|
||||
const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
|
||||
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 180 },
|
||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
|
||||
@@ -157,7 +157,7 @@ async function adjust(source: object) {
|
||||
async function remove(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value) return
|
||||
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.orderNo || '该货物'}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
if (!confirmed || !plan.value) return
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
@@ -232,7 +232,7 @@ onMounted(load)
|
||||
<DataTable :data="plan.items" :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" 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" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></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="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
@@ -240,13 +240,13 @@ onMounted(load)
|
||||
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
|
||||
<template #actions="{ row }"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template><template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<AppDialog v-model="assignOpen" title="分配候选货物" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p><p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ 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" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /></div></AppDialog>
|
||||
<AppDialog v-model="assignOpen" title="分配候选货物" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p><p>{{ selectedCandidate.inboundCode || '入仓码暂缺' }} · 可用 {{ 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" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /></div></AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -262,7 +262,7 @@ onMounted(load)
|
||||
.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
|
||||
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.summary-band { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||
.summary-band { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
|
||||
.summary-band span { color: var(--cc-muted); font-size: 12px; }
|
||||
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
|
||||
Reference in New Issue
Block a user