Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts: # src/modules/container-loading/pages/GlobalSmartLoadingView.vue # src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue # src/modules/domestic-containers/pages/DomesticContainersView.vue
This commit is contained in:
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
|
||||
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
|
||||
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||
getCustomerContainerTransitSummary,
|
||||
@@ -66,7 +66,7 @@ describe('platform container loading API', () => {
|
||||
it('normalizes every long id in candidates and plans', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', data: container })
|
||||
.mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, customerId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } })
|
||||
.mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } })
|
||||
expect((await getContainer('9')).id).toBe(String(container.id))
|
||||
expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', orderStatuses: ['PENDING_REVIEW', 'WAIT_MEASURE'], page: 1, size: 20 })).toMatchObject({ records: [{ accountId: '1', customerId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }], total: 1 })
|
||||
const candidateParams = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
|
||||
@@ -144,6 +144,17 @@ describe('platform container loading API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses one endpoint to remove all unpicked plan items', async () => {
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
|
||||
|
||||
await removeAllUnpickedSmartLoadingItems('10', 6)
|
||||
|
||||
expect(http.delete).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/smart-loading/plans/10/unpicked-items',
|
||||
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-unpicked-') } },
|
||||
)
|
||||
})
|
||||
|
||||
it('uses stable container id and versioned clearance document actions', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
|
||||
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
|
||||
|
||||
@@ -259,23 +259,26 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue
|
||||
...result,
|
||||
page: numberValue(result.page, query.page), size: numberValue(result.size, query.size),
|
||||
total: numberValue(result.total), pages: numberValue(result.pages),
|
||||
records: result.records.map((raw) => ({
|
||||
...raw, accountId: id(raw.accountId), customerId: id(raw.customerId), inboundId: optionalId(raw.inboundId),
|
||||
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
|
||||
coLoadGroupId: optionalId(raw.coLoadGroupId),
|
||||
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
|
||||
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
|
||||
locked: raw.locked == null ? null : Boolean(raw.locked),
|
||||
availablePackageCount: numberValue(raw.availablePackageCount),
|
||||
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
|
||||
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
|
||||
averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm),
|
||||
availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm),
|
||||
groupMembers: raw.groupMembers.map((member) => ({
|
||||
...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
|
||||
availablePackageCount: numberValue(member.availablePackageCount),
|
||||
})),
|
||||
})),
|
||||
records: result.records.map((raw) => {
|
||||
const accountId = id(raw.accountId)
|
||||
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),
|
||||
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
|
||||
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
|
||||
locked: raw.locked == null ? null : Boolean(raw.locked),
|
||||
availablePackageCount: numberValue(raw.availablePackageCount),
|
||||
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
|
||||
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
|
||||
averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm),
|
||||
availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm),
|
||||
groupMembers: raw.groupMembers.map((member) => ({
|
||||
...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
|
||||
availablePackageCount: numberValue(member.availablePackageCount),
|
||||
})),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,3 +351,11 @@ export async function removeSmartLoadingGroup(planId: string, groupId: string, e
|
||||
return plan(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function removeAllUnpickedSmartLoadingItems(planId: string, expectedPlanVersion: number) {
|
||||
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
|
||||
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/unpicked-items`,
|
||||
{ data: { expectedPlanVersion, operationId: operationId('remove-unpicked') } },
|
||||
)
|
||||
return plan(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import SmartLoadingDialog from './SmartLoadingDialog.vue'
|
||||
describe('SmartLoadingDialog', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('expands to the viewport and can return to the normal dialog size', async () => {
|
||||
it('opens fullscreen by default, can return to normal size, and reopens fullscreen', async () => {
|
||||
const wrapper = mount(SmartLoadingDialog, {
|
||||
attachTo: document.body,
|
||||
props: { modelValue: true },
|
||||
@@ -21,19 +21,30 @@ describe('SmartLoadingDialog', () => {
|
||||
|
||||
expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false)
|
||||
expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true)
|
||||
const expand = wrapper.get('button[aria-label="展开全屏"]')
|
||||
await expand.trigger('click')
|
||||
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
|
||||
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull()
|
||||
expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the outer dialog fixed so only workbench data panes scroll', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/SmartLoadingDialog.vue'), 'utf8')
|
||||
expect(source).toContain('fullscreenable')
|
||||
expect(source).toContain('default-fullscreen')
|
||||
expect(source).not.toContain('Maximize2')
|
||||
expect(source).not.toContain('const fullscreen')
|
||||
expect(source).toContain('.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)')
|
||||
expect(source).toContain('height: 94vh; max-height: 94vh;')
|
||||
expect(source).not.toContain('.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw)')
|
||||
expect(source).toContain('flex: 1; padding: 0; overflow: hidden;')
|
||||
expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;')
|
||||
})
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Maximize2, Minimize2 } from '@lucide/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const fullscreen = ref(false)
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) {
|
||||
fullscreen.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,38 +11,21 @@ watch(open, (value) => {
|
||||
class="smart-loading-dialog"
|
||||
title="智能装柜"
|
||||
width="min(1600px, 98vw)"
|
||||
:fullscreen="fullscreen"
|
||||
fullscreenable
|
||||
default-fullscreen
|
||||
cancel-text="关闭"
|
||||
:show-confirm="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<template #header>
|
||||
<div class="smart-loading-dialog__header">
|
||||
<div class="smart-loading-dialog__heading">
|
||||
<span>智能装柜</span>
|
||||
</div>
|
||||
<el-tooltip :content="fullscreen ? '退出全屏' : '展开全屏'" placement="bottom">
|
||||
<el-button text :aria-label="fullscreen ? '退出全屏' : '展开全屏'" @click="fullscreen = !fullscreen">
|
||||
<Minimize2 v-if="fullscreen" :size="18" />
|
||||
<Maximize2 v-else :size="18" />
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<GlobalSmartLoadingView embedded />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog.is-fullscreen) { width: 100%; height: 100vh; max-height: 100vh; margin: 0 !important; border-radius: 0; }
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
|
||||
:global(.smart-loading-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
|
||||
:global(.smart-loading-dialog .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
|
||||
.smart-loading-dialog__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; color: var(--cc-text); font-size: 18px; font-weight: 600; line-height: 24px; }
|
||||
.smart-loading-dialog__heading { display: flex; min-width: 0; align-items: center; gap: 24px; }
|
||||
.smart-loading-dialog__heading > span { flex: 0 0 auto; }
|
||||
.smart-loading-dialog__header :deep(.el-button) { flex: 0 0 auto; margin-right: 24px; color: var(--cc-muted); }
|
||||
@media (max-width: 720px) {
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
listSmartLoadingCandidates,
|
||||
listSmartLoadingWorkbenchContainers,
|
||||
recommendSmartLoading,
|
||||
adjustSmartLoadingItem,
|
||||
removeAllUnpickedSmartLoadingItems,
|
||||
removeSmartLoadingGroup,
|
||||
removeSmartLoadingItem,
|
||||
} from '../api/platformContainerLoading'
|
||||
@@ -47,6 +49,7 @@ vi.mock('../api/platformContainerLoading', () => ({
|
||||
adjustSmartLoadingItem: vi.fn(),
|
||||
removeSmartLoadingItem: vi.fn(),
|
||||
removeSmartLoadingGroup: vi.fn(),
|
||||
removeAllUnpickedSmartLoadingItems: vi.fn(),
|
||||
pushSmartLoadingPreload: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/modules/warehouses', () => ({
|
||||
@@ -453,7 +456,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
|
||||
expect(document.body.textContent).toContain('发货仓:义乌仓 · 渠道:美森正班')
|
||||
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班海卡包税')
|
||||
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
@@ -466,10 +469,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
it('allows a pending-review order to join through the provisional assignment command', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||
...candidate,
|
||||
customerId: '1',
|
||||
customerId: '',
|
||||
inboundId: null,
|
||||
orderStatusSnapshot: 'PENDING_REVIEW',
|
||||
inboundCode: 'EXXHK2609020361',
|
||||
channelCode: 'SLOW',
|
||||
channelName: '慢船',
|
||||
unitGrossWeightKg: null,
|
||||
averageUnitVolumeCbm: null,
|
||||
availableVolumeCbm: null,
|
||||
@@ -488,6 +493,9 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
|
||||
expect(document.body.textContent).toContain('平均单箱体积 待测量')
|
||||
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
|
||||
expect(document.body.textContent).toContain('订单渠道:慢船')
|
||||
expect(document.body.textContent).toContain('这是人工排柜,确认后仍会加入')
|
||||
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
|
||||
;([...document.body.querySelectorAll('button')]
|
||||
.find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
@@ -518,11 +526,32 @@ describe('GlobalSmartLoadingView', () => {
|
||||
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
||||
expect(source).toContain('@change="adjust(row)"')
|
||||
expect(source).toContain(':controls="false"')
|
||||
expect(source).toContain('@blur="adjust(row)" @keyup.enter="adjust(row)"')
|
||||
expect(source).not.toContain('<Save')
|
||||
expect(source).toContain('.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||
})
|
||||
|
||||
it('saves a typed package count through the adjustment API when editing finishes', async () => {
|
||||
vi.mocked(adjustSmartLoadingItem).mockResolvedValue({
|
||||
...plan,
|
||||
version: 3,
|
||||
items: [{ ...plan.items[0], packageCount: 4, version: 2 }],
|
||||
})
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const quantityInput = wrapper.findAllComponents(ElInputNumber).find((input) => input.props('modelValue') === 6)
|
||||
expect(quantityInput?.props('controls')).toBe(false)
|
||||
quantityInput?.vm.$emit('update:modelValue', 4)
|
||||
await flushPromises()
|
||||
quantityInput?.vm.$emit('blur')
|
||||
await flushPromises()
|
||||
|
||||
expect(adjustSmartLoadingItem).toHaveBeenCalledTimes(1)
|
||||
expect(adjustSmartLoadingItem).toHaveBeenCalledWith(plan.id, plan.items[0].id, 4, plan.version, plan.items[0].version)
|
||||
})
|
||||
|
||||
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
@@ -717,13 +746,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).toContain('trace-remove-1')
|
||||
})
|
||||
|
||||
it('removes every item with the latest returned plan version', async () => {
|
||||
it('removes every unpicked item with one bulk request', 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.mocked(removeAllUnpickedSmartLoadingItems).mockResolvedValueOnce(emptyPlan)
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
|
||||
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
@@ -733,8 +761,9 @@ describe('GlobalSmartLoadingView', () => {
|
||||
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(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledTimes(1)
|
||||
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledWith(plan.id, 2)
|
||||
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('当前柜次暂无装柜明细')
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
listSmartLoadingCandidates,
|
||||
listSmartLoadingWorkbenchContainers,
|
||||
recommendSmartLoading,
|
||||
removeAllUnpickedSmartLoadingItems,
|
||||
removeSmartLoadingGroup,
|
||||
removeSmartLoadingItem,
|
||||
} from '../api/platformContainerLoading'
|
||||
@@ -74,6 +75,11 @@ let containerRequest = 0
|
||||
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
|
||||
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
|
||||
const selectedContainer = computed(() => containers.value.find((item) => item.bookingId === selectedContainerId.value) || null)
|
||||
const crossChannelAssignment = computed(() => Boolean(
|
||||
selectedCandidate.value?.channelCode
|
||||
&& selectedContainer.value?.channelCode
|
||||
&& selectedCandidate.value.channelCode !== selectedContainer.value.channelCode,
|
||||
))
|
||||
const orchestratableContainers = computed(() => containers.value)
|
||||
const completedContainerCount = computed(() => 0)
|
||||
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
|
||||
@@ -211,7 +217,7 @@ function preferredContainerId() {
|
||||
}
|
||||
|
||||
const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
|
||||
@@ -224,13 +230,13 @@ const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' },
|
||||
]
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 80, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
|
||||
{ key: 'packageCount', label: '分配箱数', width: 100, slotName: 'packageCount' },
|
||||
{ key: 'grossWeightKg', label: '毛重', width: 80, slotName: 'weight' },
|
||||
{ key: 'volumeCbm', label: '体积', width: 80, slotName: 'volume' },
|
||||
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
|
||||
{ key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
|
||||
]
|
||||
|
||||
function formatNumber(value: number, digits = 3) {
|
||||
@@ -474,7 +480,7 @@ async function assign() {
|
||||
const selected = selectedCandidate.value!
|
||||
acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW'
|
||||
? await assignPendingReviewSmartLoadingOrder(plan.value!.id, {
|
||||
customerId: selected.customerId,
|
||||
customerId: selected.accountId,
|
||||
orderId: selected.orderId,
|
||||
inboundCode: selected.inboundCode || '',
|
||||
packageCount: assignForm.packageCount,
|
||||
@@ -495,7 +501,7 @@ async function assign() {
|
||||
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
|
||||
if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') {
|
||||
return Boolean(candidate.blockReason) || candidate.locked === true
|
||||
|| !candidate.inboundCode || candidate.availablePackageCount < 1
|
||||
|| !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1
|
||||
}
|
||||
return Boolean(candidate.blockReason) || candidate.locked === true
|
||||
|| !candidate.inboundId
|
||||
@@ -521,8 +527,9 @@ async function recommend() {
|
||||
}
|
||||
async function adjust(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value) return
|
||||
if (!plan.value || mutating.value) return
|
||||
const count = Number(quantityDrafts[item.id])
|
||||
if (count === item.packageCount) return
|
||||
if (!Number.isInteger(count) || count < item.minPackageCount) {
|
||||
ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`)
|
||||
return
|
||||
@@ -572,20 +579,7 @@ async function removeAll() {
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
let current = plan.value!
|
||||
for (const inboundItem of removableItems) {
|
||||
if (inboundItem.coLoadGroup) {
|
||||
current = await removeSmartLoadingGroup(current.id, inboundItem.coLoadGroup.groupId, current.version)
|
||||
acceptPlan(current)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
acceptPlan(await removeAllUnpickedSmartLoadingItems(plan.value!.id, plan.value!.version))
|
||||
await loadCandidateData()
|
||||
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
|
||||
}, '全部移除失败', { pickingConflict: true })
|
||||
@@ -595,6 +589,7 @@ async function mutate(
|
||||
fallback: string,
|
||||
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
|
||||
) {
|
||||
if (mutating.value) return
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try {
|
||||
@@ -685,9 +680,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<el-button
|
||||
v-if="canAssign && assignable"
|
||||
size="small"
|
||||
type="primary"
|
||||
type="text"
|
||||
plain
|
||||
:disabled="candidateBlocked(row as SmartLoadingCandidateRow)"
|
||||
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
|
||||
@click="openAssign(row)"
|
||||
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
|
||||
</template>
|
||||
@@ -763,10 +758,10 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<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="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" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<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-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="mutating || !canAssign || !editable || !row.canAdjust" controls-position="right" @change="adjust(row)" placeholder="请输入调整箱数" /><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating || !canAssign || !editable || !row.canAdjust" placeholder="请输入调整箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||
</DataTable>
|
||||
@@ -778,7 +773,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<div v-if="selectedCandidate" class="assign-dialog">
|
||||
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
|
||||
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
|
||||
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 渠道:{{ resolveSmartLoadingChannelGroup(selectedContainer?.channelCode, selectedContainer?.channelName)?.name || '—' }}</p>
|
||||
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ selectedContainer?.channelName || '—' }}</p>
|
||||
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工排柜,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
|
||||
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
|
||||
|
||||
@@ -132,7 +132,8 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
|
||||
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
||||
expect(source).toContain('@change="adjust(row)"')
|
||||
expect(source).toContain(':controls="false"')
|
||||
expect(source).toContain('@blur="adjust(row)" @keyup.enter="adjust(row)"')
|
||||
expect(source).not.toContain('<Save')
|
||||
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||
})
|
||||
|
||||
@@ -262,8 +262,9 @@ async function recommend() {
|
||||
}
|
||||
async function adjust(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value) return
|
||||
if (!plan.value || mutating.value) return
|
||||
const count = Number(quantityDrafts[item.id])
|
||||
if (count === item.packageCount) return
|
||||
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
@@ -363,7 +364,7 @@ onMounted(load)
|
||||
<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 #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="mutating || !canAssign || !editable || !row.canAdjust" controls-position="right" @change="adjust(row)" placeholder="请输入调整箱数" /><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.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :controls="false" :disabled="mutating || !canAssign || !editable || !row.canAdjust" placeholder="请输入调整箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><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="当前仓库暂无可排柜货物">
|
||||
|
||||
Reference in New Issue
Block a user