Merge remote-tracking branch 'origin/release/test' into dev

# Conflicts:
#	src/modules/container-loading/pages/GlobalSmartLoadingView.vue
#	src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
This commit is contained in:
2026-09-04 10:37:12 +08:00
18 changed files with 404 additions and 208 deletions
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeSmartLoadingGroup, removeSmartLoadingItem,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
@@ -68,7 +68,10 @@ describe('platform container loading API', () => {
.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' } })
expect((await getContainer('9')).id).toBe(String(container.id))
expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', 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 })
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
expect(candidateParams.getAll('orderStatuses')).toEqual(['PENDING_REVIEW', 'WAIT_MEASURE'])
expect(candidateParams.has('orderStatus')).toBe(false)
const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
expect(params.getAll('channelGroupCodes')).toEqual(['EXX', 'HEDE'])
expect(params.get('warehouseId')).toBe('5')
@@ -88,22 +91,41 @@ describe('platform container loading API', () => {
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
})
it('uses the dedicated provisional assignment command for a pending-review order', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
await assignPendingReviewSmartLoadingOrder('10', {
customerId: '21', orderId: '31', inboundCode: 'EXXHK2609020361',
packageCount: 100, expectedVersion: 5,
})
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/pending-review-assignments',
expect.objectContaining({
customerId: '21', orderId: '31', inboundCode: 'EXXHK2609020361',
packageCount: 100, expectedVersion: 5,
operationId: expect.stringContaining('assign-pending-review-'),
}),
)
})
it('queries workbench containers with the same global filters and excludes the retired booking parameter', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
records: [{ bookingId: 9007199254740993n, bookingNo: 'BK-1', cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HC', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }],
page: '1', size: '200', total: '1', pages: '1',
} })
const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', expectedArrivalStart: '2026-09-01', expectedArrivalEnd: '2026-09-03', orderStatus: 'WAIT_SPLIT', page: 1, size: 200 })
const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', createdStart: '2026-09-01', createdEnd: '2026-09-03', orderStatuses: ['WAIT_SPLIT'], page: 1, size: 200 })
expect(result).toMatchObject({ records: [{ bookingId: '9007199254740993', warehouseId: '8', planId: '9007199254740994', version: 2 }], total: 1 })
const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
expect(params.getAll('channelGroupCodes')).toEqual(['MATSON_CLX', 'EXX'])
expect(params.get('customerId')).toBe('12')
expect(params.get('inboundCode')).toBe('IN-1')
expect(params.get('expectedArrivalStart')).toBe('2026-09-01')
expect(params.get('expectedArrivalEnd')).toBe('2026-09-03')
expect(params.get('orderStatus')).toBe('WAIT_SPLIT')
expect(params.get('createdStart')).toBe('2026-09-01')
expect(params.get('createdEnd')).toBe('2026-09-03')
expect(params.has('orderStatus')).toBe(false)
expect(params.has('orderStatuses')).toBe(false)
expect(params.has('bookingId')).toBe(false)
})
@@ -119,6 +119,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
blockedReason: raw.blockedReason || null,
pdaTaskId: optionalId(raw.pdaTaskId),
pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion),
coLoadGroups: (raw.coLoadGroups || []).map((group) => ({
...group,
groupId: id(group.groupId), lockedPlanId: optionalId(group.lockedPlanId),
memberOrderCount: numberValue(group.memberOrderCount),
inboundFragmentCount: numberValue(group.inboundFragmentCount),
fullPackageCount: numberValue(group.fullPackageCount),
memberInboundIds: (group.memberInboundIds || []).map(id),
})),
items: (raw.items || []).map(item),
}
}
@@ -228,20 +236,23 @@ export async function completeContainerClearance(containerId: string, expectedVe
return document(unwrapApiResponse(response))
}
function smartLoadingQueryParams(query: SmartLoadingCandidateQuery) {
function smartLoadingQueryParams(query: SmartLoadingCandidateQuery, includeOrderStatuses = false) {
const params = new URLSearchParams()
for (const channelGroupCode of query.channelGroupCodes) params.append('channelGroupCodes', channelGroupCode)
if (includeOrderStatuses) {
for (const orderStatus of query.orderStatuses || []) params.append('orderStatuses', orderStatus)
}
const optionalParams: Array<[string, string | number | undefined]> = [
['warehouseId', query.warehouseId], ['customerId', query.customerId], ['inboundCode', query.inboundCode],
['expectedArrivalStart', query.expectedArrivalStart], ['expectedArrivalEnd', query.expectedArrivalEnd],
['orderStatus', query.orderStatus], ['page', query.page], ['size', query.size],
['createdStart', query.createdStart], ['createdEnd', query.createdEnd],
['page', query.page], ['size', query.size],
]
for (const [key, value] of optionalParams) if (value !== undefined && value !== '') params.append(key, String(value))
return params
}
export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQuery) {
const params = smartLoadingQueryParams(query)
const params = smartLoadingQueryParams(query, true)
const response = await http.get<ApiEnvelope<SmartLoadingCandidatePage>, ApiEnvelope<SmartLoadingCandidatePage>>(`${smartLoadingPath}/inbound-candidates`, { params })
const result = unwrapApiResponse(response)
return {
@@ -249,7 +260,7 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue
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: id(raw.inboundId),
...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),
@@ -277,6 +288,8 @@ export async function listSmartLoadingWorkbenchContainers(query: SmartLoadingCan
total: numberValue(result.total), pages: numberValue(result.pages),
records: result.records.map((raw) => ({
...raw, bookingId: id(raw.bookingId), warehouseId: id(raw.warehouseId), planId: optionalId(raw.planId),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
version: numberValue(raw.version),
})),
}
@@ -297,6 +310,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI
return plan(unwrapApiResponse(response))
}
export async function assignPendingReviewSmartLoadingOrder(planId: string, payload: { customerId: string; orderId: string; inboundCode: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pending-review-assignments`,
{ ...payload, operationId: operationId('assign-pending-review') },
)
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingGroup(planId: string, inboundId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments`,
@@ -10,6 +10,7 @@ import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
import type { ContainerBookingResource } from '../types'
import {
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
getSmartLoadingPlanByBooking,
@@ -40,6 +41,7 @@ vi.mock('../api/platformContainerLoading', () => ({
listSmartLoadingWorkbenchContainers: vi.fn(),
createSmartLoadingPlan: vi.fn(),
assignSmartLoadingItem: vi.fn(),
assignPendingReviewSmartLoadingOrder: vi.fn(),
assignSmartLoadingGroup: vi.fn(),
recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(),
@@ -47,7 +49,13 @@ vi.mock('../api/platformContainerLoading', () => ({
removeSmartLoadingGroup: vi.fn(),
pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
vi.mock('@/modules/warehouses', () => ({
listAllPlatformWarehouses: vi.fn().mockResolvedValue([
{ id: '8', warehouseName: '义乌仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' },
{ id: '18', warehouseName: '东莞仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' },
]),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) }))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
@@ -62,6 +70,7 @@ const plan = {
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
}
const candidate = {
@@ -87,7 +96,8 @@ function containerPage(records: ContainerBookingResource[] = [container]) {
records: records.map((item) => ({
bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelCode: item.channelCode || '', channelName: item.channelName || '',
containerType: item.containerType, planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version,
containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg,
planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version,
})),
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
} as Awaited<ReturnType<typeof listSmartLoadingWorkbenchContainers>>
@@ -100,10 +110,18 @@ function mockContainerSearch(records: ContainerBookingResource[]) {
}
describe('GlobalSmartLoadingView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%; }')
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
})
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
mockContainerSearch([container])
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
@@ -113,7 +131,6 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
expect(wrapper.text()).toContain('候选装柜明细')
@@ -143,13 +160,12 @@ describe('GlobalSmartLoadingView', () => {
total: 1,
pages: 1,
})
mockContainerSearch([{ ...container, shippingCompanyId: '32', shippingCompanyName: 'EXX', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]))
mockContainerSearch([container])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('EXX')
expect(wrapper.text()).not.toContain('纽约EXX海铁')
expect(wrapper.text()).toContain('美森正班')
expect(wrapper.text()).not.toContain('美森正班海卡包税')
expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
})
@@ -278,7 +294,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).toContain('MSCU-DG')
const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' })
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] })
await flushPromises()
expect(wrapper.text()).toContain('MSCU-DG')
@@ -305,7 +321,7 @@ describe('GlobalSmartLoadingView', () => {
await flushPromises()
const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' })
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] })
await flushPromises()
expect(wrapper.find('.channel-tags .el-checkbox-button.is-checked').text()).toBe('美森正班')
@@ -336,7 +352,7 @@ describe('GlobalSmartLoadingView', () => {
const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', expectedArrivalRange: [], orderStatus: '',
warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', createdRange: [], orderStatuses: [],
})
await flushPromises()
@@ -361,7 +377,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain(': candidate.availablePackageCount')
})
it('filters candidates by expected arrival range and order status', async () => {
it('filters candidates by creation range and multiple order statuses', async () => {
vi.mocked(listSmartLoadingCandidates)
.mockResolvedValueOnce(candidatePage([
{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' },
@@ -374,7 +390,7 @@ describe('GlobalSmartLoadingView', () => {
const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length
wrapper.getComponent(QueryToolbar).vm.$emit('search', {
customerId: '', inboundCode: '', orderStatus: 'WAIT_SPLIT', expectedArrivalRange: ['2026-09-10', '2026-09-12'],
customerId: '', inboundCode: '', orderStatuses: ['WAIT_SPLIT', 'LOADING'], createdRange: ['2026-09-10', '2026-09-12'],
})
await flushPromises()
@@ -382,7 +398,7 @@ describe('GlobalSmartLoadingView', () => {
expect(candidates).toContain('IN-MATCH')
expect(candidates).not.toContain('IN-WRONG-STATUS')
expect(candidates).not.toContain('IN-WRONG-DATE')
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], expectedArrivalStart: '2026-09-10', expectedArrivalEnd: '2026-09-12', orderStatus: 'WAIT_SPLIT', page: 1, size: 20 })
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], createdStart: '2026-09-10', createdEnd: '2026-09-12', orderStatuses: ['WAIT_SPLIT', 'LOADING'], page: 1, size: 20 })
expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount)
expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 200 })
})
@@ -436,6 +452,8 @@ describe('GlobalSmartLoadingView', () => {
await buttons[0].trigger('click')
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).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
@@ -445,6 +463,43 @@ describe('GlobalSmartLoadingView', () => {
wrapper.unmount()
})
it('allows a pending-review order to join through the provisional assignment command', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate,
customerId: '1',
inboundId: null,
orderStatusSnapshot: 'PENDING_REVIEW',
inboundCode: 'EXXHK2609020361',
unitGrossWeightKg: null,
averageUnitVolumeCbm: null,
availableVolumeCbm: null,
blockReason: null,
}]))
vi.mocked(assignPendingReviewSmartLoadingOrder).mockResolvedValue(plan)
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.candidate-pane').text()).toContain('待审核')
const add = wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button')
.find((button) => button.text().includes('加入'))!
expect(add.classes()).not.toContain('is-disabled')
await add.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
expect(document.body.textContent).toContain('平均单箱体积 待测量')
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
;([...document.body.querySelectorAll('button')]
.find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click()
await flushPromises()
expect(assignPendingReviewSmartLoadingOrder).toHaveBeenCalledWith(plan.id, {
customerId: '1', orderId: '7', inboundCode: 'EXXHK2609020361',
packageCount: 12, expectedVersion: plan.version,
})
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
wrapper.unmount()
})
it('blocks an incomplete three-member candidate group without exposing member assignment', async () => {
const members = ['401', '402', '403'].map((inboundId, index) => ({ accountId: `${index + 1}`, inboundId, orderId: `50${index}`, orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, customerName: `客户${index}`, availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }))
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
@@ -476,8 +531,8 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('0.22%')
expect(wrapper.text()).toContain('0.04%')
expect(wrapper.text()).toContain('0.16%')
expect(wrapper.text()).toContain('0.03%')
expect(wrapper.text()).not.toContain('0.169999999')
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
})
@@ -487,16 +542,19 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>
expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'expectedArrivalRange', 'orderStatus'])
expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([{ label: '义乌仓', value: '8' }])
expect(fields.find((field) => field.key === 'orderStatus')?.options).toEqual([
{ value: '', label: '全部' },
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }>; props?: Record<string, unknown> }>
expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'orderStatuses'])
expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([
{ label: '义乌仓', value: '8' },
{ label: '东莞仓', value: '18' },
])
expect(fields.find((field) => field.key === 'orderStatuses')?.options).toEqual([
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
])
expect(fields.find((field) => field.key === 'orderStatuses')?.props).toMatchObject({ multiple: true, collapseTags: true, maxCollapseTags: 2 })
expect(source).not.toContain('目的仓')
expect(wrapper.text()).not.toContain('税率')
@@ -577,7 +635,7 @@ describe('GlobalSmartLoadingView', () => {
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('CB-3')
expect(wrapper.text()).toContain('当前柜次已完成装柜,不可继续编辑')
expect(wrapper.text()).toContain('暂无符合条件的货柜')
expect(wrapper.text()).not.toContain('缺少渠道快照')
})
@@ -628,7 +686,7 @@ describe('GlobalSmartLoadingView', () => {
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(3)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
expect(warningMessage).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('装柜操作失败')
expect(wrapper.text()).not.toContain('request-1')
@@ -759,6 +817,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('size="small"')
expect(source).toContain('type="danger" plain')
expect(source).toContain(':max="adjustmentMax(row)"')
expect(source).toContain("{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }")
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)
@@ -779,7 +838,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }')
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
expect(source).toContain('min-height: 112px')
expect(source).toContain("label: '预计到货时间'")
expect(source).toContain("label: '创建日期'")
expect(source).toContain("label: '订单状态'")
expect(source).toContain('.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }')
expect(source).toContain('.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }')
@@ -4,19 +4,21 @@ import { Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
listSmartLoadingWorkbenchContainers,
recommendSmartLoading,
@@ -24,7 +26,6 @@ import {
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type {
ContainerBookingResource,
SmartLoadingCandidate,
SmartLoadingPlan,
SmartLoadingWorkbenchContainer,
@@ -40,7 +41,7 @@ const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: fa
const access = useAccessPreviewStore()
const containers = ref<SmartLoadingWorkbenchContainer[]>([])
const containerCatalog = ref<ContainerBookingResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const selectedContainerId = ref('')
@@ -61,8 +62,8 @@ const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
const query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] })
const appliedQuery = ref<QueryModel>({ ...query.value, orderStatuses: [] })
const candidatePage = ref(1)
const candidatePageSize = ref(20)
const candidateTotal = ref(0)
@@ -73,8 +74,8 @@ 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 orchestratableContainers = computed(() => containerCatalog.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containerCatalog.value.filter((item) => item.planStatus === 'COMPLETED').length)
const orchestratableContainers = computed(() => containers.value)
const completedContainerCount = computed(() => 0)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
@@ -84,17 +85,12 @@ const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.va
)).length)
const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS)
const warehouseOptions = computed(() => {
const options = new Map<string, string>()
for (const item of orchestratableContainers.value) {
if (item.warehouseId && !options.has(item.warehouseId)) options.set(item.warehouseId, item.warehouseName?.trim() || '未命名发货仓')
}
return [...options].map(([value, label]) => ({ value, label }))
return warehouses.value.map((warehouse) => ({ value: String(warehouse.id), label: warehouse.warehouseName }))
})
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const selectedContainerResource = computed(() => containerCatalog.value.find((item) => item.id === selectedContainerId.value) || null)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number; totalVolumeCbm: number }
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
@@ -111,21 +107,21 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) {
return Number(candidate.unitGrossWeightKg || 0) * Number(candidate.availablePackageCount || 0)
return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0)
}
function candidateTotalVolume(candidate: SmartLoadingCandidate) {
return candidate.availableVolumeCbm == null
? Number(candidate.averageUnitVolumeCbm || 0) * Number(candidate.availablePackageCount || 0)
? candidate.averageUnitVolumeCbm == null ? null : Number(candidate.averageUnitVolumeCbm) * Number(candidate.availablePackageCount || 0)
: Number(candidate.availableVolumeCbm)
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
return candidates.value.map((candidate) => {
const members: Array<{ accountId: string; inboundId: string; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
const members: Array<{ accountId: string; inboundId: string | null; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
? candidate.groupMembers
: [candidate]
return {
...candidate,
memberInboundIds: members.map((member) => member.inboundId),
memberInboundIds: members.map((member) => member.inboundId).filter((id): id is string => Boolean(id)),
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
@@ -176,25 +172,27 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
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))
const volumeLimit = computed(() => Number(selectedContainerResource.value?.capacityCbm || 0) * 1.1)
const weightLimit = computed(() => Number(selectedContainerResource.value?.maxWeightKg || 0) * 1.1)
const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.5)
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.5)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const candidateOrderStatusOptions = [
{ value: '', label: '全部' },
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
]
function candidateStatusLabel(status: string | null | undefined) {
return candidateOrderStatusOptions.find((item) => item.value === status)?.label || '—'
}
const queryFields = computed<QueryField[]>(() => [
{ key: 'warehouseId', type: 'select', label: '发货仓', placeholder: '全部发货仓', width: 160, options: warehouseOptions.value, props: { filterable: true } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 210 },
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到货时间', placeholder: '请选择预计到货时间范围', width: 260 },
{ key: 'orderStatus', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 160, options: candidateOrderStatusOptions },
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260 },
{ key: 'orderStatuses', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 210, options: candidateOrderStatusOptions, props: { multiple: true, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 2 } },
])
const filteredContainers = computed(() => containers.value)
@@ -216,7 +214,8 @@ const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, 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 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 },
// { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
{ key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' },
@@ -228,7 +227,7 @@ const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 80, 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 },
{ 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' },
@@ -237,6 +236,9 @@ const itemColumns: DataTableColumn[] = [
function formatNumber(value: number, digits = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
}
function formatMeasurement(value: number | null | undefined, unit: string, digits = 3) {
return value == null ? '待测量' : `${formatNumber(Number(value), digits)} ${unit}`
}
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
function itemRow(source: object) { return source as SmartLoadingInboundItem }
@@ -245,17 +247,17 @@ function adjustmentMax(source: object) {
const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0
return Math.max(item.minPackageCount, item.packageCount + remaining)
}
function displayCabinetNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) {
function displayCabinetNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return '待生成'
const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
}
function hasRealContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) {
function hasRealContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return false
const value = String(container.containerNo || '').trim()
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
}
function displayActualContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) {
function displayActualContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录'
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
@@ -264,6 +266,7 @@ function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> =
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
plan.value = next
planGroupsByInboundId.value = loadSmartLoadingPlanCoLoadGroups(next)
noDraft.value = false
syncDrafts(next, preserved)
const container = selectedContainer.value
@@ -283,8 +286,8 @@ function clearSelectionData() {
}
function candidateQueryParams(): Parameters<typeof listSmartLoadingCandidates>[0] {
const arrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange)
? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10))
const createdRange = Array.isArray(appliedQuery.value.createdRange)
? appliedQuery.value.createdRange.map((value) => String(value || '').slice(0, 10))
: []
const params: Parameters<typeof listSmartLoadingCandidates>[0] = {
channelGroupCodes: [...selectedChannelCodes.value],
@@ -294,15 +297,17 @@ function candidateQueryParams(): Parameters<typeof listSmartLoadingCandidates>[0
const warehouseId = String(appliedQuery.value.warehouseId || '')
const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim()
const orderStatus = String(appliedQuery.value.orderStatus || '') as typeof params.orderStatus
const orderStatuses = Array.isArray(appliedQuery.value.orderStatuses)
? appliedQuery.value.orderStatuses.map((value) => String(value)).filter(Boolean) as NonNullable<typeof params.orderStatuses>
: []
if (warehouseId) params.warehouseId = warehouseId
if (customerId) params.customerId = customerId
if (inboundCode) params.inboundCode = inboundCode
if (arrivalRange.length === 2) {
params.expectedArrivalStart = arrivalRange[0]
params.expectedArrivalEnd = arrivalRange[1]
if (createdRange.length === 2) {
params.createdStart = createdRange[0]
params.createdEnd = createdRange[1]
}
if (orderStatus) params.orderStatus = orderStatus
if (orderStatuses.length) params.orderStatuses = orderStatuses
return params
}
@@ -350,7 +355,6 @@ async function loadSelectedPlan(preserved: Record<string, number> = {}) {
if (request !== selectionRequest) return
if (planResult.value) {
acceptPlan(planResult.value, preserved, false)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value)
syncDrafts(planResult.value, preserved)
}
else noDraft.value = planResult.missing
@@ -391,17 +395,11 @@ async function loadAll(preserved: Record<string, number> = {}) {
loading.value = true
loadError.value = null
try {
const firstPage = await listContainers({ page: 1, size: 100 })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containerCatalog.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
const availableChannelCodes = new Set(channelOptions.value.map((item) => item.code))
selectedChannelCodes.value = selectedChannelCodes.value.filter((code) => availableChannelCodes.has(code))
if (!selectedChannelCodes.value.length) {
selectedChannelCodes.value = [channelOptions.value.find((channel) => orchestratableContainers.value.some((item) => (
resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === channel.code
)))?.code || channelOptions.value[0]?.code || '']
selectedChannelCodes.value = [channelOptions.value[0]?.code || '']
}
await loadWorkbenchData(preserved)
} catch (error) {
@@ -437,7 +435,10 @@ async function selectChannels(codes: string[]) {
await loadWorkbenchData()
}
async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model }
appliedQuery.value = {
...model,
orderStatuses: Array.isArray(model.orderStatuses) ? model.orderStatuses.map((value) => String(value)) : [],
}
candidatePage.value = 1
await loadCandidateData()
}
@@ -471,20 +472,34 @@ async function assign() {
}
await mutate(async () => {
const selected = selectedCandidate.value!
acceptPlan(selected.coLoadGroupId
acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW'
? await assignPendingReviewSmartLoadingOrder(plan.value!.id, {
customerId: selected.customerId,
orderId: selected.orderId,
inboundCode: selected.inboundCode || '',
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
})
: selected.coLoadGroupId && selected.inboundId
? await assignSmartLoadingGroup(plan.value!.id, selected.inboundId, plan.value!.version)
: await assignSmartLoadingItem(plan.value!.id, {
inboundId: selected.inboundId,
inboundId: selected.inboundId!,
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
}))
assignOpen.value = false
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次')
}, '分配明细失败', { preserveAssign: true })
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') {
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundCode || candidate.availablePackageCount < 1
}
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundId
|| !candidate.inboundCode
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
|| candidate.availablePackageCount < 1
}
@@ -500,7 +515,7 @@ async function recommend() {
if (!plan.value) throw error
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
}
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success('智能推荐已生成')
}, '智能推荐失败')
}
@@ -516,7 +531,7 @@ async function adjust(source: object) {
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 loadWorkbenchData()
await loadCandidateData()
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
@@ -533,7 +548,7 @@ async function remove(source: object) {
await mutate(async () => {
if (row.coLoadGroup) {
acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
}
@@ -544,7 +559,7 @@ async function remove(source: object) {
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
acceptPlan(current)
}
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success('该入仓码已从当前柜次移除')
}, '移除装柜明细失败', { pickingConflict: true })
}
@@ -571,7 +586,7 @@ async function removeAll() {
acceptPlan(current)
}
}
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
}, '全部移除失败', { pickingConflict: true })
}
@@ -676,11 +691,13 @@ onMounted(() => { void load(); void loadCustomers() })
@click="openAssign(row)"
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
</template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template>
<template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0), 1) }} kg</template>
<template #candidateTotalWeight="{ row }">{{ formatNumber(Number(row.totalGrossWeightKg || 0), 1) }} kg</template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template>
<template #candidateTotalVolume="{ row }">{{ formatNumber(Number(row.totalVolumeCbm || 0)) }} m³</template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
<template #averageUnitVolume="{ row }">{{ formatMeasurement(row.averageUnitVolumeCbm, 'm³') }}</template>
<template #candidateTotalVolume="{ row }">{{ formatMeasurement(row.totalVolumeCbm, 'm³') }}</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</section>
@@ -747,8 +764,9 @@ onMounted(() => { void load(); void loadCustomers() })
</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 #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>固定 {{ row.coLoadGroup.fullPackageCount }} 箱 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span></div><span v-else>普通单票</span></template>
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><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="请输入调整箱数" /><small v-if="row.detailCount > 1 && editable">多规格,需删除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></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 #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable>
@@ -759,13 +777,15 @@ onMounted(() => { void load(); void loadCustomers() })
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 渠道:{{ resolveSmartLoadingChannelGroup(selectedContainer?.channelCode, selectedContainer?.channelName)?.name || '—' }}</p>
<template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form>
</template>
@@ -831,7 +851,9 @@ onMounted(() => { void load(); void loadCustomers() })
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; gap: 4px; }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; }
.candidate-block { color: var(--el-color-danger) !important; }
@media (max-width: 1180px) {
:deep(.smart-loading-query .query-toolbar__filters),:deep(.smart-loading-query .query-toolbar__tail),:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: wrap; overflow: visible; }
@@ -29,7 +29,7 @@ const container = {
const planResponse = {
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, items: [],
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], items: [],
}
const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }
function candidatePage(records: unknown[]) {
@@ -43,6 +43,15 @@ function candidatePage(records: unknown[]) {
}
describe('SmartLoadingWorkbenchView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;')
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
})
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
@@ -63,7 +72,7 @@ describe('SmartLoadingWorkbenchView', () => {
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% 容量上限')
expect(wrapper.text()).toContain('150% 容量上限')
})
it('uses loading plan capabilities to keep PDA increments editable', async () => {
@@ -179,7 +188,7 @@ describe('SmartLoadingWorkbenchView', () => {
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text() === '分配')!.trigger('click')
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('本次计划装柜箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
@@ -9,6 +9,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
@@ -62,12 +63,12 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
return candidates.value.map((candidate) => {
const members: Array<{ inboundId: string; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
const members: Array<{ inboundId: string | null; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
? candidate.groupMembers
: [candidate]
return {
...candidate,
memberInboundIds: members.map((member) => member.inboundId),
memberInboundIds: members.map((member) => member.inboundId).filter((id): id is string => Boolean(id)),
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
@@ -117,8 +118,8 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
})
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)
const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.1)
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.5)
const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.5)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
@@ -129,7 +130,7 @@ const itemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 120, slotName: 'volume' },
@@ -139,7 +140,7 @@ const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, showOverflowTooltip: true },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
@@ -157,7 +158,7 @@ function explainCandidate(source: object) {
const weight = Number(candidate.unitGrossWeightKg || 0)
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
}
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '待生成'
@@ -217,15 +218,17 @@ function openAssign(source: object) {
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
const currentPlan = plan.value
const selected = selectedCandidate.value
const inboundId = selected?.inboundId
if (!currentPlan || !selected || !inboundId || assignForm.packageCount < 1) return
if (selected.coLoadGroupId && (selected.blockReason || selected.locked)) return
mutating.value = true
mutationError.value = null
try {
const selected = selectedCandidate.value
const next = selected.coLoadGroupId
? await assignSmartLoadingGroup(plan.value.id, selected.inboundId, plan.value.version)
: await assignSmartLoadingItem(plan.value.id, { inboundId: selected.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version })
? await assignSmartLoadingGroup(currentPlan.id, inboundId, currentPlan.version)
: await assignSmartLoadingItem(currentPlan.id, { inboundId, packageCount: assignForm.packageCount, expectedVersion: currentPlan.version })
await acceptPlanWithGroups(next)
assignOpen.value = false
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '货物已分配')
@@ -234,6 +237,7 @@ async function assign() {
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundId
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
|| candidate.availablePackageCount < 1
}
@@ -347,7 +351,7 @@ onMounted(load)
</section>
<template v-else>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</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>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 150% 上限)</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>重量占用(含 150% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<el-empty v-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
<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 端需要刷新后继续操作。" />
@@ -356,15 +360,17 @@ onMounted(load)
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>全部入仓片段联动,固定 {{ row.coLoadGroup.fullPackageCount }} 箱</span></div><span v-else>普通单票</span></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else content="普通单票" /></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><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="请输入调整箱数" /><small v-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格,需删除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></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" :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 #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
@@ -401,8 +407,9 @@ onMounted(load)
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; width: fit-content; gap: 3px; }
.candidate-group > span,.candidate-group > small { color: var(--cc-muted); font-size: 11px; }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: 11px; }
.candidate-group .candidate-block { color: var(--el-color-danger); }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
</style>
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { loadSmartLoadingPlanCoLoadGroups } from './smartLoadingPlanGroups'
import type { SmartLoadingPlan } from './types'
@@ -13,24 +13,22 @@ const plan = {
id: '99', planNo: 'LP-99', status: 'EDITING', bookingId: '88', containerNo: null, voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', pdaTaskId: null,
pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [{ groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2,
inboundFragmentCount: 2, fullPackageCount: 12, memberInboundIds: ['201', '202'] }],
items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }],
} satisfies SmartLoadingPlan
describe('smart loading plan co-load groups', () => {
it('rebuilds the locked group from the current inbound association contract', async () => {
const lookup = vi.fn(async (inboundId: string) => ({
inboundId, inboundCode: `IN-${inboundId}`, loadingPlanId: '99', coLoadGroupId: '9001', coLoadGroupNo: 'CLG-9001', items: [],
}))
const result = await loadSmartLoadingPlanCoLoadGroups(plan, lookup)
expect(lookup).toHaveBeenCalledTimes(2)
it('uses the group snapshot already returned with the plan without per-inbound requests', () => {
const result = loadSmartLoadingPlanCoLoadGroups(plan)
expect(result.get('201')).toEqual({
groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2, inboundFragmentCount: 2, fullPackageCount: 12,
})
expect(result.get('202')).toBe(result.get('201'))
})
it('leaves ordinary plan items ungrouped', async () => {
const result = await loadSmartLoadingPlanCoLoadGroups(plan, async (inboundId) => ({ inboundId, inboundCode: `IN-${inboundId}`, items: [] }))
it('leaves ordinary plan items ungrouped', () => {
const result = loadSmartLoadingPlanCoLoadGroups({ ...plan, coLoadGroups: [] })
expect(result.size).toBe(0)
})
})
@@ -1,5 +1,3 @@
import { getLoadingAssociationSummaryByInboundId, type LoadingAssociationSummary } from '@/shared/api/inboundCodeRelations'
import { groupSmartLoadingItems } from './smartLoadingItems'
import type { SmartLoadingPlan } from './types'
export interface SmartLoadingPlanCoLoadGroup {
@@ -11,37 +9,18 @@ export interface SmartLoadingPlanCoLoadGroup {
fullPackageCount: number
}
type AssociationLookup = (inboundId: string) => Promise<LoadingAssociationSummary>
export async function loadSmartLoadingPlanCoLoadGroups(
plan: SmartLoadingPlan,
lookup: AssociationLookup = getLoadingAssociationSummaryByInboundId,
) {
const inboundRows = groupSmartLoadingItems(plan.items || [])
if (!inboundRows.length) return new Map<string, SmartLoadingPlanCoLoadGroup>()
const associations = await Promise.all(inboundRows.map(async (row) => ({
row,
association: await lookup(row.inboundId),
})))
const membersByGroupId = new Map<string, typeof associations>()
for (const entry of associations) {
const groupId = String(entry.association.coLoadGroupId || '')
if (!groupId) continue
membersByGroupId.set(groupId, [...(membersByGroupId.get(groupId) || []), entry])
}
export function loadSmartLoadingPlanCoLoadGroups(plan: SmartLoadingPlan) {
const groupsByInboundId = new Map<string, SmartLoadingPlanCoLoadGroup>()
for (const [groupId, members] of membersByGroupId) {
for (const source of plan.coLoadGroups || []) {
const group = {
groupId,
groupNo: members.find((entry) => entry.association.coLoadGroupNo)?.association.coLoadGroupNo || '同柜不可拆组',
lockedPlanId: members.find((entry) => entry.association.loadingPlanId)?.association.loadingPlanId || plan.id,
memberOrderCount: new Set(members.map((entry) => entry.row.orderId)).size,
inboundFragmentCount: members.length,
fullPackageCount: members.reduce((sum, entry) => sum + entry.row.packageCount, 0),
groupId: source.groupId,
groupNo: source.groupNo || '同柜不可拆组',
lockedPlanId: source.lockedPlanId || plan.id,
memberOrderCount: source.memberOrderCount,
inboundFragmentCount: source.inboundFragmentCount,
fullPackageCount: source.fullPackageCount,
} satisfies SmartLoadingPlanCoLoadGroup
for (const { row } of members) groupsByInboundId.set(row.inboundId, group)
for (const inboundId of source.memberInboundIds) groupsByInboundId.set(inboundId, group)
}
return groupsByInboundId
}
+15 -4
View File
@@ -232,7 +232,7 @@ export interface ContainerSaveRequest {
export interface SmartLoadingCandidate {
accountId: string
customerId: string
inboundId: string
inboundId: string | null
orderId: string
orderNo: string | null
inboundCode: string | null
@@ -277,9 +277,9 @@ export interface SmartLoadingCandidateQuery {
warehouseId?: string
customerId?: string
inboundCode?: string
expectedArrivalStart?: string
expectedArrivalEnd?: string
orderStatus?: 'PENDING_REVIEW' | 'WAIT_MEASURE' | 'WAIT_SPLIT' | 'LOADING'
createdStart?: string
createdEnd?: string
orderStatuses?: Array<'PENDING_REVIEW' | 'WAIT_MEASURE' | 'WAIT_SPLIT' | 'LOADING'>
page: number
size: number
}
@@ -302,6 +302,8 @@ export interface SmartLoadingWorkbenchContainer {
channelCode: string
channelName: string
containerType: string
capacityCbm: number | null
maxWeightKg: number | null
planId: string | null
planNo: string | null
planStatus: Exclude<ContainerPlanStatus, 'COMPLETED'>
@@ -358,5 +360,14 @@ export interface SmartLoadingPlan {
pdaTaskId: string | null
pdaTaskStatus: 'LOADING' | 'COMPLETED' | null
pdaTaskVersion: number | null
coLoadGroups: Array<{
groupId: string
groupNo: string
lockedPlanId: string | null
memberOrderCount: number
inboundFragmentCount: number
fullPackageCount: number
memberInboundIds: string[]
}>
items: SmartLoadingItem[]
}
@@ -12,6 +12,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformInbounds } from '../core/api'
@@ -82,26 +83,26 @@ const queryFields = computed<QueryField[]>(() => [
])
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' },
...(isReview.value && access.can(actionPermission.value) ? [{
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: (row: InboundOrderSummary) => row.measureReviewStatus === 'PENDING' && !batchReviewLoading.value,
} satisfies DataTableColumn] : []),
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
{ key: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'inboundCode' },
{ key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus' },
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
{ key: 'customerName', label: '客户', minWidth: 160, slotName: 'customerName' },
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
{ key: 'businessType', label: '业务类型', minWidth: 120, slotName: 'businessType' },
{ key: 'channelName', label: '渠道', minWidth: 140, slotName: 'channelName' },
{ key: 'totalPackageCount', label: '总箱数', width: 92, slotName: 'totalPackageCount' },
{ key: 'palletCount', label: '托盘数', width: 92, slotName: 'palletCount' },
{ key: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 128, slotName: 'actualGrossWeightKg' },
{ key: 'actualNetWeightKg', label: '货物重量(kg)', minWidth: 128, slotName: 'actualNetWeightKg' },
{ key: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 128, slotName: 'actualVolumeCbm' },
{ key: 'measureCompletedAt', label: '提交时间', minWidth: 164, slotName: 'measureCompletedAt' },
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 120, slotName: 'measureReviewStatus' },
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 140, slotName: 'expectedArrivalAt' },
{ key: 'customerName', label: '客户', minWidth: 140, slotName: 'customerName' },
{ key: 'warehouseName', label: '仓库', minWidth: 120, slotName: 'warehouseName' },
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 120, slotName: 'deliveryWarehouseName' },
{ key: 'businessType', label: '业务类型', minWidth: 100, slotName: 'businessType' },
{ key: 'channelName', label: '渠道', minWidth: 100, slotName: 'channelName' },
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' },
{ key: 'palletCount', label: '托盘数', width: 90, slotName: 'palletCount' },
{ key: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 100, slotName: 'actualGrossWeightKg' },
{ key: 'actualNetWeightKg', label: '货物重量(kg)', minWidth: 100, slotName: 'actualNetWeightKg' },
{ key: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 100, slotName: 'actualVolumeCbm' },
{ key: 'measureCompletedAt', label: '提交时间', minWidth: 140, slotName: 'measureCompletedAt' },
])
function text(value: unknown) {
@@ -381,11 +382,11 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
<template #customerName="{ row }"><LineClampText :content="(row as InboundOrderSummary).customerName" /></template>
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
<template #businessType="{ row }">{{ firstLegBusinessTypeLabel((row as InboundOrderSummary).businessType) }}</template>
<template #channelName="{ row }">{{ text((row as InboundOrderSummary).channelName || (row as InboundOrderSummary).channelCode) }}</template>
<template #channelName="{ row }"><LineClampText :content="(row as InboundOrderSummary).channelName || (row as InboundOrderSummary).channelCode" /></template>
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
<template #palletCount="{ row }">{{ text((row as InboundOrderSummary).palletCount) }}</template>
<template #actualGrossWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}</template>
@@ -11,6 +11,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -424,7 +425,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
<template #feeStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</template>
<template #feeReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).feeReviewStatus, inboundReviewStatusLabels) }}</template>
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
<template #customerName="{ row }"><LineClampText :content="(row as InboundOrderSummary).customerName" /></template>
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
<template #loadingStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).loadingStatus, loadingStatusLabels) }}</template>
@@ -434,7 +435,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
<template #cabinetNo="{ row }">{{ text((row as InboundOrderSummary).cabinetNo) }}</template>
<template #companyName="{ row }">{{ text((row as InboundOrderSummary).companyName) }}</template>
<template #businessType="{ row }">{{ firstLegBusinessTypeLabel((row as InboundOrderSummary).businessType) }}</template>
<template #channelName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).channelName) }}</strong><span>{{ text((row as InboundOrderSummary).channelCode) }}</span></div></template>
<template #channelName="{ row }"><div class="primary-cell"><LineClampText :content="(row as InboundOrderSummary).channelName" /><span>{{ text((row as InboundOrderSummary).channelCode) }}</span></div></template>
<template #taxMode="{ row }">{{ firstLegTaxModeLabel((row as InboundOrderSummary).taxMode) }}</template>
<template #containerMode="{ row }">{{ firstLegContainerModeLabel((row as InboundOrderSummary).containerMode) }}</template>
<template #customsInspection="{ row }">{{ (row as InboundOrderSummary).customsInspection === null ? '—' : (row as InboundOrderSummary).customsInspection ? '是' : '否' }}</template>
@@ -10,6 +10,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { loadingListStatusLabel } from '../status'
@@ -50,20 +51,20 @@ const fields = computed<QueryField[]>(() => [
])
const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 165 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
{ key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
{ key: 'actions', label: '操作', fixed: 'left', width: 88, slotName: 'actions', hideable: false, movable: false },
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 120 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 120 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 140 },
// { key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 80 },
{ key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' },
{ key: 'containerType', prop: 'containerType', label: '箱型', width: 100 },
{ key: 'containerType', prop: 'containerType', label: '箱型', width: 80 },
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
{ key: 'skuCount', prop: 'skuCount', label: 'SKU 数', width: 90, align: 'right' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
{ key: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right', slotName: 'volume' },
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 125, align: 'right', slotName: 'grossWeight' },
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
{ key: 'skuCount', prop: 'skuCount', label: 'SKU 数', width: 90, align: 'right' },
{ key: 'totalVolumeCbm', label: '总体积(m³)', width: 90, align: 'right', slotName: 'volume' },
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 90, align: 'right', slotName: 'grossWeight' },
{ key: 'createdAt', label: '创建时间', minWidth: 140, slotName: 'createdAt' },
] : [
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
@@ -211,7 +212,7 @@ onMounted(() => {
<el-button v-if="canView" size="small" type="primary" plain @click="openDetail(row)"><Eye :size="15" />查看</el-button>
</div>
</template>
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、') || '—' }}</template>
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="loadingListStatusLabel(row.status)" /></template>
<template #grossWeight="{ row }">{{ formatNumber(row.totalGrossWeightKg) }}</template>
@@ -93,6 +93,12 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('PartnersView real API integration', () => {
it('clamps long unit names with the shared component', () => {
expect(source).toContain("LineClampText from '@/shared/components/LineClampText.vue'")
expect(source).toContain('<LineClampText :content="(row as PlatformPartnerResource).partnerName" />')
expect(source).not.toContain('.partner-cell strong')
})
it('keeps operation first and separates the prototype contact columns', () => {
const actionsIndex = source.indexOf("{ key: 'actions', label: '操作'")
const nameIndex = source.indexOf("{ key: 'partner', label: '单位名称'")
@@ -11,6 +11,7 @@ import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -408,7 +409,7 @@ onMounted(() => {
<el-button v-if="access.can('platform:counterparty:status')" size="small" plain :disabled="loading" :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
</template>
<template #partner="{ row }"><div class="partner-cell"><span><Building2 :size="18" /></span><div><strong>{{ (row as PlatformPartnerResource).partnerName }}</strong></div></div></template>
<template #partner="{ row }"><div class="partner-cell"><span><Building2 :size="18" /></span><div><LineClampText :content="(row as PlatformPartnerResource).partnerName" /></div></div></template>
<template #type="{ row }"><div class="partner-types"><el-tag v-for="type in (row as PlatformPartnerResource).types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></div></template>
<template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template>
<template #status="{ row }"><el-tag :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
@@ -495,7 +496,7 @@ onMounted(() => {
.partner-cell { display: flex; align-items: center; gap: 10px; }
.partner-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.partner-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.partner-cell strong { overflow-wrap: anywhere; }
.partner-cell :deep(.line-clamp-text) { width: 100%; }
.partner-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
.partner-address svg { flex: 0 0 15px; margin-top: 3px; color: var(--cc-muted); }
.partner-detail { min-height: 180px; padding: 20px 24px 28px; box-sizing: border-box; }
@@ -8,6 +8,13 @@ const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/routes.ts'), 'utf8')
describe('supplier formal pages', () => {
it('clamps long supplier and represented-carrier names with the shared component', () => {
expect(source).toContain("LineClampText from '@/shared/components/LineClampText.vue'")
expect(source).toContain('<template #name="{ row }"><LineClampText :content="row.name" /></template>')
expect(source).toContain('<template #carrierCodes="{ row }"><LineClampText')
expect(source).not.toContain('class="supplier-name"')
})
it('keeps actions first and fixed left while guarding all operations with backend permissions', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 260, fixed: 'left'")
expect(source).toContain("access.can('platform:supplier:create')")
+12 -11
View File
@@ -8,6 +8,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -44,14 +45,14 @@ const queryFields = computed<QueryField[]>(() => [
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '供应商名称', minWidth: 230, slotName: 'name' },
{ key: 'category', label: '类别', width: 90, slotName: 'category' },
{ key: 'carrierCodes', label: '可代理船公司', minWidth: 240, slotName: 'carrierCodes' },
{ key: 'region', label: '地区', minWidth: 110 },
{ key: 'legalRepresentative', label: '法人', minWidth: 120 },
{ key: 'contactName', label: '联系人', minWidth: 110 },
{ key: 'phone', label: '联系电话', minWidth: 150 },
{ key: 'businessLicenseNo', label: '营业执照号', minWidth: 190 },
{ key: 'name', label: '供应商名称', minWidth: 200, slotName: 'name' },
{ key: 'category', label: '类别', width: 70, slotName: 'category' },
{ key: 'carrierCodes', label: '可代理船公司', minWidth: 200, slotName: 'carrierCodes' },
{ key: 'region', label: '地区', minWidth: 100 },
{ key: 'legalRepresentative', label: '法人', minWidth: 100 },
{ key: 'contactName', label: '联系人', minWidth: 120 },
{ key: 'phone', label: '联系电话', minWidth: 120 },
{ key: 'businessLicenseNo', label: '营业执照号', minWidth: 120 },
{ key: 'license', label: '营业执照', width: 125, slotName: 'license' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
{ key: 'settlementPeriod', label: '结算周期', width: 120, slotName: 'settlement' },
@@ -153,9 +154,9 @@ onActivated(() => {
<el-button v-if="access.can('platform:supplier:status')" size="small" plain :type="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))"><Power :size="15" />{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
</template>
<template #name="{ row }"><strong class="supplier-name">{{ row.name }}</strong></template>
<template #name="{ row }"><LineClampText :content="row.name" /></template>
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
<template #carrierCodes="{ row }">{{ supplierRow(row).category === 'BOOKING' ? formatCarrierCodes(supplierRow(row).carrierCodes) : '—' }}</template>
<template #carrierCodes="{ row }"><LineClampText :content="supplierRow(row).category === 'BOOKING' ? formatCarrierCodes(supplierRow(row).carrierCodes) : null" /></template>
<template #license="{ row }"><el-button size="small" plain type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[supplierRow(row).status] ?? '—'" /></template>
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
@@ -168,7 +169,7 @@ onActivated(() => {
<style scoped>
.supplier-list-top { display: grid; gap: 16px; }.supplier-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }.supplier-metrics button { min-width: 0; padding: 12px 14px; border: 1px solid #e7edf5; border-radius: 8px; background: #f8fafc; color: var(--cc-text); text-align: left; cursor: pointer; }.supplier-metrics button.active { border-color: var(--cc-primary); background: #f7f8ff; box-shadow: inset 0 3px 0 var(--cc-primary); }.supplier-metrics span { display: block; margin-bottom: 6px; color: #64748b; font-size: 12px; }.supplier-metrics strong { color: #1e293b; font-size: 20px; }.supplier-metrics small { margin-left: 3px; color: #64748b; font-size: 12px; }.supplier-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.supplier-query-toolbar { border: 0; }.supplier-tabs { display: flex; gap: 6px; padding: 10px 14px; overflow-x: auto; border-bottom: 1px solid var(--cc-border); background: #fafbfc; }.supplier-tabs button { min-width: 76px; padding: 7px 20px; border: 0; border-radius: 6px; background: #f1f3f5; color: #555; cursor: pointer; }.supplier-tabs button.active { background: var(--cc-primary); color: #fff; font-weight: 650; }
.row-actions { display: flex; align-items: center; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }.supplier-name { overflow-wrap: anywhere; }
.row-actions { display: flex; align-items: center; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } }
</style>
@@ -2311,7 +2311,7 @@ onBeforeUnmount(() => {
</template>
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
<template #channel="{ row }"><div class="channel-cell"><span>{{ (row as UnifiedOrderResource).channelName || '—' }}</span><el-tag v-if="(row as UnifiedOrderResource).channelChanged" size="small" type="warning">曾变更渠道</el-tag></div></template>
<template #channel="{ row }"><div class="channel-cell"><LineClampText :content="(row as UnifiedOrderResource).channelName" /><el-tag v-if="(row as UnifiedOrderResource).channelChanged" size="small" type="warning">曾变更渠道</el-tag></div></template>
<template #estimatedVolume="{ row }">
<div class="planning-cell">
<el-input-number
@@ -2358,7 +2358,7 @@ onBeforeUnmount(() => {
</div>
</div>
</template>
<template #field="{ row, column }"><LineClampText v-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
<template #field="{ row, column }"><LineClampText v-if="column.property === 'customerName'" :content="displayField(row as UnifiedOrderResource, column.property)" /><LineClampText v-else-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong></div></template>
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8')
describe('LineClampText domestic list consumers', () => {
it.each([
['统一订单', 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'],
['入库任务', 'src/modules/inbounds/components/InboundTaskList.vue'],
['入库单', 'src/modules/inbounds/pages/InboundOrdersView.vue'],
['装柜清单', 'src/modules/loading-lists/components/LoadingListsView.vue'],
['柜次智能装柜', 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'],
['全局智能装柜', 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'],
])('%s列表复用共享的行数省略组件', (_name, path) => {
expect(read(path)).toContain("LineClampText from '@/shared/components/LineClampText.vue'")
})
it('统一订单客户与渠道都使用一行省略,并保留渠道变更标记', () => {
const source = read('src/modules/unified-orders/pages/UnifiedOrdersView.vue')
expect(source).toContain("column.property === 'customerName'")
expect(source).toContain('<LineClampText :content="(row as UnifiedOrderResource).channelName" />')
expect(source).toContain('曾变更渠道')
})
it.each([
['src/modules/inbounds/components/InboundTaskList.vue', '#customerName', '#channelName'],
['src/modules/inbounds/pages/InboundOrdersView.vue', '#customerName', '#channelName'],
])('入库列表客户与渠道均使用共享组件:%s', (path, customerSlot, channelSlot) => {
const source = read(path)
expect(source).toContain(`<template ${customerSlot}`)
expect(source).toContain(`<template ${channelSlot}`)
expect(source.match(/<LineClampText/g)?.length).toBeGreaterThanOrEqual(2)
})
it('装柜清单将多客户汇总限制为一行', () => {
const source = read('src/modules/loading-lists/components/LoadingListsView.vue')
expect(source).toContain('<template #customers="{ row }"><LineClampText')
})
it.each([
'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue',
'src/modules/container-loading/pages/GlobalSmartLoadingView.vue',
])('智能装柜候选与已装载客户列使用相同的一行省略:%s', (path) => {
const source = read(path)
expect(source.match(/slotName: 'customerName'/g)).toHaveLength(2)
expect(source.match(/<template #customerName/g)).toHaveLength(2)
expect(source.match(/<LineClampText :content=/g)?.length).toBeGreaterThanOrEqual(2)
})
})