Release/test 合并上生产 #2
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
|
||||
listContainers, listSmartLoadingCandidates, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
|
||||
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||
getCustomerContainerTransitSummary,
|
||||
@@ -66,10 +66,13 @@ describe('platform container loading API', () => {
|
||||
it('normalizes every long id in candidates and plans', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', data: container })
|
||||
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', 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 }] })
|
||||
.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('7', '5')).toMatchObject([{ accountId: '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 }])
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } })
|
||||
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 })
|
||||
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')
|
||||
expect(params.has('bookingId')).toBe(false)
|
||||
})
|
||||
|
||||
it('sends plan and item versions plus operation identifiers', async () => {
|
||||
@@ -85,6 +88,25 @@ 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('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 })
|
||||
|
||||
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.has('bookingId')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
|
||||
|
||||
@@ -16,6 +16,9 @@ import type {
|
||||
ContainerSimulationCreateRequest,
|
||||
ContainerOceanBookingLinkRequest,
|
||||
SmartLoadingCandidate,
|
||||
SmartLoadingCandidatePage,
|
||||
SmartLoadingCandidateQuery,
|
||||
SmartLoadingWorkbenchContainerPage,
|
||||
SmartLoadingItem,
|
||||
SmartLoadingPlan,
|
||||
BusinessDocument,
|
||||
@@ -225,21 +228,58 @@ export async function completeContainerClearance(containerId: string, expectedVe
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listSmartLoadingCandidates(bookingId: string, warehouseId: string) {
|
||||
const response = await http.get<ApiEnvelope<SmartLoadingCandidate[]>, ApiEnvelope<SmartLoadingCandidate[]>>(`${smartLoadingPath}/inbound-candidates`, { params: { bookingId, warehouseId } })
|
||||
return unwrapApiResponse(response).map((raw) => ({
|
||||
...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId),
|
||||
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
|
||||
coLoadGroupId: optionalId(raw.coLoadGroupId),
|
||||
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
|
||||
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
|
||||
locked: raw.locked == null ? null : Boolean(raw.locked),
|
||||
availablePackageCount: numberValue(raw.availablePackageCount),
|
||||
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
|
||||
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
|
||||
averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm),
|
||||
availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm),
|
||||
}))
|
||||
function smartLoadingQueryParams(query: SmartLoadingCandidateQuery) {
|
||||
const params = new URLSearchParams()
|
||||
for (const channelGroupCode of query.channelGroupCodes) params.append('channelGroupCodes', channelGroupCode)
|
||||
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],
|
||||
]
|
||||
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 response = await http.get<ApiEnvelope<SmartLoadingCandidatePage>, ApiEnvelope<SmartLoadingCandidatePage>>(`${smartLoadingPath}/inbound-candidates`, { params })
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
page: numberValue(result.page, query.page), size: numberValue(result.size, query.size),
|
||||
total: numberValue(result.total), pages: numberValue(result.pages),
|
||||
records: result.records.map((raw) => ({
|
||||
...raw, accountId: id(raw.accountId), customerId: id(raw.customerId), inboundId: id(raw.inboundId),
|
||||
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
|
||||
coLoadGroupId: optionalId(raw.coLoadGroupId),
|
||||
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
|
||||
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
|
||||
locked: raw.locked == null ? null : Boolean(raw.locked),
|
||||
availablePackageCount: numberValue(raw.availablePackageCount),
|
||||
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
|
||||
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
|
||||
averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm),
|
||||
availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm),
|
||||
groupMembers: raw.groupMembers.map((member) => ({
|
||||
...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
|
||||
availablePackageCount: numberValue(member.availablePackageCount),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSmartLoadingWorkbenchContainers(query: SmartLoadingCandidateQuery) {
|
||||
const response = await http.get<ApiEnvelope<SmartLoadingWorkbenchContainerPage>, ApiEnvelope<SmartLoadingWorkbenchContainerPage>>(`${smartLoadingPath}/containers`, { params: smartLoadingQueryParams(query) })
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
page: numberValue(result.page, query.page), size: numberValue(result.size, query.size),
|
||||
total: numberValue(result.total), pages: numberValue(result.pages),
|
||||
records: result.records.map((raw) => ({
|
||||
...raw, bookingId: id(raw.bookingId), warehouseId: id(raw.warehouseId), planId: optionalId(raw.planId),
|
||||
version: numberValue(raw.version),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSmartLoadingPlanByBooking(bookingId: string) {
|
||||
|
||||
@@ -7,12 +7,15 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||
import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import {
|
||||
assignSmartLoadingGroup,
|
||||
assignSmartLoadingItem,
|
||||
getSmartLoadingPlanByBooking,
|
||||
listContainers,
|
||||
listSmartLoadingCandidates,
|
||||
listSmartLoadingWorkbenchContainers,
|
||||
recommendSmartLoading,
|
||||
removeSmartLoadingGroup,
|
||||
removeSmartLoadingItem,
|
||||
@@ -34,6 +37,7 @@ vi.mock('../api/platformContainerLoading', () => ({
|
||||
listContainers: vi.fn(),
|
||||
getSmartLoadingPlanByBooking: vi.fn(),
|
||||
listSmartLoadingCandidates: vi.fn(),
|
||||
listSmartLoadingWorkbenchContainers: vi.fn(),
|
||||
createSmartLoadingPlan: vi.fn(),
|
||||
assignSmartLoadingItem: vi.fn(),
|
||||
assignSmartLoadingGroup: vi.fn(),
|
||||
@@ -69,14 +73,41 @@ const candidate = {
|
||||
availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null,
|
||||
locked: null, blockReason: null,
|
||||
}
|
||||
function candidatePage(records: unknown[], page = 1, size = 20, total = records.length) {
|
||||
return {
|
||||
records: records.map((record) => {
|
||||
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
|
||||
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
|
||||
}),
|
||||
page, size, total, pages: total ? Math.ceil(total / size) : 0,
|
||||
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
|
||||
}
|
||||
function containerPage(records: ContainerBookingResource[] = [container]) {
|
||||
return {
|
||||
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,
|
||||
})),
|
||||
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
|
||||
} as Awaited<ReturnType<typeof listSmartLoadingWorkbenchContainers>>
|
||||
}
|
||||
function mockContainerSearch(records: ContainerBookingResource[]) {
|
||||
vi.mocked(listSmartLoadingWorkbenchContainers).mockImplementation(async (query) => containerPage(records.filter((item) => (
|
||||
item.planStatus !== 'COMPLETED'
|
||||
&& (!query.warehouseId || item.warehouseId === query.warehouseId)
|
||||
&& query.channelGroupCodes.includes(resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code || '')
|
||||
))))
|
||||
}
|
||||
|
||||
describe('GlobalSmartLoadingView', () => {
|
||||
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([candidate])
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
|
||||
})
|
||||
|
||||
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
|
||||
@@ -85,7 +116,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
|
||||
expect(wrapper.text()).toContain('候选装柜明细')
|
||||
expect(wrapper.text()).toContain('美森正班')
|
||||
expect(wrapper.findAll('.channel-tags .el-checkbox-button')).toHaveLength(9)
|
||||
@@ -113,7 +144,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ 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, 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 }]))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -131,6 +163,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
mockContainerSearch([{ ...container, cabinetNo: 'CAB-260814-0001', containerNo: 'SPLIT-814022117898chain1' }])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -154,6 +187,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
mockContainerSearch([{ ...container, cabinetNo: '2088475410134773762', containerNo: null, channelName: '美森正班海卡包税', warehouseName: '义乌国内仓', containerType: '40HC' }])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -180,6 +214,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
channelName: '合德海卡包税',
|
||||
}
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [container, other], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([container, other])
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId }))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -213,6 +248,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
channelName: '美森正班海派',
|
||||
}
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [container, parcelContainer], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([container, parcelContainer])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -234,6 +270,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
warehouseName: '东莞仓',
|
||||
}
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [container, dongguanContainer], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([container, dongguanContainer])
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId }))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -246,7 +283,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
expect(wrapper.text()).toContain('MSCU-DG')
|
||||
expect(wrapper.text()).not.toContain('MSCU-1')
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(dongguanContainer.id, dongguanContainer.warehouseId)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: dongguanContainer.warehouseId, page: 1, size: 20 })
|
||||
})
|
||||
|
||||
it('keeps the selected channel when the warehouse changes even if that warehouse has no matching container', async () => {
|
||||
@@ -262,6 +299,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
channelName: '美森加班海卡',
|
||||
}
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [container, dongguanOvertimeContainer], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([container, dongguanOvertimeContainer])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -271,14 +309,15 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.find('.channel-tags .el-checkbox-button.is-checked').text()).toBe('美森正班')
|
||||
expect(wrapper.findAll('.container-option')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('MSCU-DG-MAX')
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(2)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: '18', page: 1, size: 20 })
|
||||
})
|
||||
|
||||
it('filters candidates by an active name-only customer selector and accountId', () => {
|
||||
it('sends the active name-only customer selector to the server candidate query', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
|
||||
expect(source).toContain("label: customer.tenantName")
|
||||
expect(source).toContain('member.accountId === customerId')
|
||||
expect(source).toContain('params.customerId = customerId')
|
||||
expect(source).not.toContain('customer.tenantCode')
|
||||
})
|
||||
|
||||
@@ -288,11 +327,13 @@ describe('GlobalSmartLoadingView', () => {
|
||||
})
|
||||
|
||||
it('filters candidates by expected arrival range and order status', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
||||
vi.mocked(listSmartLoadingCandidates)
|
||||
.mockResolvedValueOnce(candidatePage([
|
||||
{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' },
|
||||
{ ...candidate, inboundId: '52', inboundCode: 'IN-WRONG-STATUS', orderStatusSnapshot: 'LOADING', expectedArrivalAt: '2026-09-11T08:00:00' },
|
||||
{ ...candidate, inboundId: '53', inboundCode: 'IN-WRONG-DATE', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-20T08:00:00' },
|
||||
])
|
||||
]))
|
||||
.mockResolvedValueOnce(candidatePage([{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' }]))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -305,15 +346,24 @@ 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(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], expectedArrivalStart: '2026-09-10', expectedArrivalEnd: '2026-09-12', orderStatus: 'WAIT_SPLIT', page: 1, size: 200 })
|
||||
})
|
||||
|
||||
it('paginates large inbound candidate results with twenty rows by default', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(Array.from({ length: 25 }, (_, index) => ({
|
||||
...candidate,
|
||||
inboundId: String(100 + index),
|
||||
orderId: String(200 + index),
|
||||
inboundCode: `IN-${String(index + 1).padStart(3, '0')}`,
|
||||
})))
|
||||
vi.mocked(listSmartLoadingCandidates)
|
||||
.mockResolvedValueOnce(candidatePage(Array.from({ length: 20 }, (_, index) => ({
|
||||
...candidate,
|
||||
inboundId: String(100 + index),
|
||||
orderId: String(200 + index),
|
||||
inboundCode: `IN-${String(index + 1).padStart(3, '0')}`,
|
||||
})), 1, 20, 25))
|
||||
.mockResolvedValueOnce(candidatePage(Array.from({ length: 5 }, (_, index) => ({
|
||||
...candidate,
|
||||
inboundId: String(120 + index),
|
||||
orderId: String(220 + index),
|
||||
inboundCode: `IN-${String(index + 21).padStart(3, '0')}`,
|
||||
})), 2, 20, 25))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -329,11 +379,14 @@ describe('GlobalSmartLoadingView', () => {
|
||||
})
|
||||
|
||||
it('aggregates a two-order candidate group and calls only whole-group assignment', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
||||
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||
{ accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051-B', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '10', destinationWarehouseCode: 'JFK8', destinationWarehouseName: 'JFK8', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 2, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.4, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-202', inboundCode: 'NYEXX2608290052', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户二', goodsSummary: '货物二', availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||
])
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||
accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', 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: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null,
|
||||
groupMembers: [
|
||||
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', customerName: '客户一', availablePackageCount: 4, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051-B', customerName: '客户一', availablePackageCount: 2, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-202', inboundCode: 'NYEXX2608290052', customerName: '客户二', availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
],
|
||||
}]))
|
||||
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(plan)
|
||||
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -356,9 +409,10 @@ describe('GlobalSmartLoadingView', () => {
|
||||
})
|
||||
|
||||
it('blocks an incomplete three-member candidate group without exposing member assignment', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(['401', '402', '403'].map((inboundId, index) => ({
|
||||
accountId: `${index + 1}`, inboundId, orderId: `50${index}`, orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: `客户${index}`, goodsSummary: `货物${index}`, availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '9900', groupNo: 'CLG-9900', memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票',
|
||||
})))
|
||||
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([{
|
||||
...candidate, inboundId: '401', orderId: '500', inboundCode: 'IN-401', coLoadGroupId: '9900', groupNo: 'CLG-9900', memberCount: 3, groupTotalBoxes: 18, availablePackageCount: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票', groupMembers: members,
|
||||
}]))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -410,23 +464,21 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).not.toContain('是否退税')
|
||||
})
|
||||
|
||||
it('filters completed loading candidates out even when all statuses are selected', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
||||
candidate,
|
||||
{ ...candidate, inboundId: '6', orderId: '8', inboundCode: 'IN-LOADED', orderStatusSnapshot: 'LOADED' },
|
||||
{ ...candidate, inboundId: '7', orderId: '9', inboundCode: 'IN-COMPLETED', orderStatusSnapshot: 'COMPLETED' },
|
||||
])
|
||||
it('relies on the server contract to exclude completed candidates', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.candidate-pane').text()).toContain('IN-2')
|
||||
expect(wrapper.get('.candidate-pane').text()).not.toContain('IN-LOADED')
|
||||
expect(wrapper.get('.candidate-pane').text()).not.toContain('IN-COMPLETED')
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
|
||||
})
|
||||
|
||||
it('prefers an editing container on first load while keeping loading containers available', async () => {
|
||||
const pushed = { ...container, id: '9007199254740995', planStatus: 'LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [pushed, container], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([pushed, container])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -437,6 +489,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
it('keeps waiting and editing empty containers without exposing their status labels', async () => {
|
||||
const waiting = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-WAIT', containerNo: null, planStatus: 'WAIT_LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [container, waiting], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([container, waiting])
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => (
|
||||
bookingId === waiting.id
|
||||
? { ...plan, bookingId: waiting.id, status: 'LOADING' as const }
|
||||
@@ -449,10 +502,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(waitingOption.text()).toContain('CB-WAIT')
|
||||
expect(waitingOption.text()).not.toContain('待装柜')
|
||||
|
||||
const candidateCalls = vi.mocked(listSmartLoadingCandidates).mock.calls.length
|
||||
await waitingOption.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(waiting.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(candidateCalls)
|
||||
expect(waitingOption.text()).toContain('CB-WAIT')
|
||||
expect(waitingOption.text()).not.toContain('拣货中')
|
||||
})
|
||||
@@ -460,12 +515,13 @@ describe('GlobalSmartLoadingView', () => {
|
||||
it('keeps picking containers available when the plan capability allows increments', async () => {
|
||||
const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 })
|
||||
mockContainerSearch([pushed])
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, bookingId: pushed.id, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 })
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(pushed.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.id, pushed.warehouseId)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
|
||||
expect(wrapper.findAll('.container-option')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('CB-2')
|
||||
expect(wrapper.text()).not.toContain('已下发 PDA,仍可维护未完成的装柜明细')
|
||||
@@ -476,6 +532,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
it('hides completed containers from the smart loading workbench', async () => {
|
||||
const completed = { ...container, id: '9007199254740997', planId: '9007199254740998', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [completed], page: 1, size: 100, total: 1, pages: 1 })
|
||||
mockContainerSearch([completed])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -488,6 +545,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
it('isolates a missing-channel row without hiding another valid container', async () => {
|
||||
const missingChannel = { ...container, id: '9007199254740997', cabinetNo: 'CB-MISSING', channelCode: null, channelName: null }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [missingChannel, container], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([missingChannel, container])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -498,17 +556,17 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
})
|
||||
|
||||
it('matches candidate goods by their own formal top-level channel code', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
||||
it('renders the channel-filtered candidate page returned by the server', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([
|
||||
{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', 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 },
|
||||
{ accountId: '2', inboundId: '8', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsSummary: '其他货物', availablePackageCount: 8, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.6, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null },
|
||||
])
|
||||
]))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('匹配企业')
|
||||
expect(wrapper.text()).not.toContain('IN-OTHER')
|
||||
expect(wrapper.text()).not.toContain('其他货物')
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
|
||||
})
|
||||
|
||||
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
|
||||
@@ -649,7 +707,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
})
|
||||
|
||||
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ 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: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ 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: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, RefreshCw, Save, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
getSmartLoadingPlanByBooking,
|
||||
listContainers,
|
||||
listSmartLoadingCandidates,
|
||||
listSmartLoadingWorkbenchContainers,
|
||||
recommendSmartLoading,
|
||||
removeSmartLoadingGroup,
|
||||
removeSmartLoadingItem,
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
ContainerBookingResource,
|
||||
SmartLoadingCandidate,
|
||||
SmartLoadingPlan,
|
||||
SmartLoadingWorkbenchContainer,
|
||||
} from '../types'
|
||||
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
||||
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
|
||||
@@ -38,8 +39,8 @@ import {
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const containers = ref<ContainerBookingResource[]>([])
|
||||
const containers = ref<SmartLoadingWorkbenchContainer[]>([])
|
||||
const containerCatalog = ref<ContainerBookingResource[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const selectedContainerId = ref('')
|
||||
@@ -48,9 +49,11 @@ const plan = ref<SmartLoadingPlan | null>(null)
|
||||
const candidates = ref<SmartLoadingCandidate[]>([])
|
||||
const loading = ref(false)
|
||||
const selectionLoading = ref(false)
|
||||
const candidateLoading = ref(false)
|
||||
const mutating = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const selectionError = ref<ApiErrorDetails | null>(null)
|
||||
const candidateError = ref<ApiErrorDetails | null>(null)
|
||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const noDraft = ref(false)
|
||||
const assignOpen = ref(false)
|
||||
@@ -62,13 +65,16 @@ const query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: ''
|
||||
const appliedQuery = ref<QueryModel>({ ...query.value })
|
||||
const candidatePage = ref(1)
|
||||
const candidatePageSize = ref(20)
|
||||
const candidateTotal = ref(0)
|
||||
let selectionRequest = 0
|
||||
let candidateRequest = 0
|
||||
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.id === selectedContainerId.value) || null)
|
||||
const orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
|
||||
const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length)
|
||||
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 missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
|
||||
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
|
||||
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
|
||||
@@ -86,9 +92,9 @@ const warehouseOptions = computed(() => {
|
||||
})
|
||||
const editable = computed(() => plan.value?.canEdit === true)
|
||||
const assignable = computed(() => plan.value?.canAssign === true)
|
||||
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
|
||||
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 & { sourceCandidates: SmartLoadingCandidate[]; 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; totalVolumeCbm: number }
|
||||
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) {
|
||||
@@ -113,34 +119,21 @@ function candidateTotalVolume(candidate: SmartLoadingCandidate) {
|
||||
: Number(candidate.availableVolumeCbm)
|
||||
}
|
||||
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||
const rows: SmartLoadingCandidateRow[] = []
|
||||
const groups = new Map<string, SmartLoadingCandidate[]>()
|
||||
for (const candidate of candidates.value) {
|
||||
if (!candidate.coLoadGroupId) {
|
||||
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1, totalGrossWeightKg: candidateTotalGrossWeight(candidate), totalVolumeCbm: candidateTotalVolume(candidate) })
|
||||
continue
|
||||
}
|
||||
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
|
||||
}
|
||||
for (const members of groups.values()) {
|
||||
const first = members[0]
|
||||
rows.push({
|
||||
...first,
|
||||
inboundCode: members.map((member) => member.inboundCode).filter(Boolean).join('、'),
|
||||
customerName: [...new Set(members.map((member) => member.customerName).filter(Boolean))].join('、'),
|
||||
goodsSummary: [...new Set(members.map((member) => member.goodsSummary).filter(Boolean))].join('、'),
|
||||
availablePackageCount: Number(first.groupTotalBoxes || members.reduce((sum, member) => sum + member.availablePackageCount, 0)),
|
||||
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
|
||||
sourceCandidates: members,
|
||||
return candidates.value.map((candidate) => {
|
||||
const members: Array<{ accountId: string; inboundId: string; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
|
||||
? candidate.groupMembers
|
||||
: [candidate]
|
||||
return {
|
||||
...candidate,
|
||||
memberInboundIds: members.map((member) => member.inboundId),
|
||||
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
|
||||
memberOrderSummaries: memberOrderSummaries(members),
|
||||
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
|
||||
inboundFragmentCount: Number(first.memberCount || members.length),
|
||||
totalGrossWeightKg: members.reduce((sum, member) => sum + candidateTotalGrossWeight(member), 0),
|
||||
totalVolumeCbm: members.reduce((sum, member) => sum + candidateTotalVolume(member), 0),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
inboundFragmentCount: Number(candidate.memberCount || members.length),
|
||||
totalGrossWeightKg: candidateTotalGrossWeight(candidate),
|
||||
totalVolumeCbm: candidateTotalVolume(candidate),
|
||||
}
|
||||
})
|
||||
})
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
@@ -183,8 +176,8 @@ 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(selectedContainer.value?.capacityCbm || 0) * 1.1)
|
||||
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.1)
|
||||
const volumeLimit = computed(() => Number(selectedContainerResource.value?.capacityCbm || 0) * 1.1)
|
||||
const weightLimit = computed(() => Number(selectedContainerResource.value?.maxWeightKg || 0) * 1.1)
|
||||
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))
|
||||
@@ -196,13 +189,6 @@ const candidateOrderStatusOptions = [
|
||||
{ value: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ value: 'LOADING', label: '装柜中' },
|
||||
]
|
||||
const candidateOrderStatusGroups: Record<string, readonly string[]> = {
|
||||
PENDING_REVIEW: ['SUBMITTED', 'PENDING_REVIEW'],
|
||||
WAIT_MEASURE: ['WAIT_MEASURE'],
|
||||
WAIT_SPLIT: ['WAIT_SPLIT'],
|
||||
LOADING: ['LOADING'],
|
||||
}
|
||||
const completedCandidateOrderStatuses = new Set(['LOADED', 'COMPLETED'])
|
||||
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 } },
|
||||
@@ -211,42 +197,19 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderStatus', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 160, options: candidateOrderStatusOptions },
|
||||
])
|
||||
|
||||
const filteredContainers = computed(() => containers.value.filter((item) => (
|
||||
['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)
|
||||
&& (!String(appliedQuery.value.warehouseId || '') || item.warehouseId === String(appliedQuery.value.warehouseId))
|
||||
&& selectedChannelCodes.value.includes(resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code || '')
|
||||
)))
|
||||
const filteredCandidates = computed(() => {
|
||||
const customerId = String(appliedQuery.value.customerId || '')
|
||||
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
|
||||
const orderStatus = String(appliedQuery.value.orderStatus || '').trim().toUpperCase()
|
||||
const arrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange)
|
||||
? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10))
|
||||
: []
|
||||
const channelCode = selectedContainer.value?.channelCode
|
||||
return displayedCandidates.value.filter((item) => {
|
||||
if (!channelCode || item.channelCode !== channelCode) return false
|
||||
if (item.sourceCandidates.some((member) => completedCandidateOrderStatuses.has(member.orderStatusSnapshot?.trim().toUpperCase() || ''))) return false
|
||||
if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false
|
||||
if (inboundCode && !item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode))) return false
|
||||
if (orderStatus && !item.sourceCandidates.some((member) => candidateOrderStatusGroups[orderStatus]?.includes(member.orderStatusSnapshot?.trim().toUpperCase() || ''))) return false
|
||||
if (arrivalRange.length === 2 && !item.sourceCandidates.some((member) => {
|
||||
const arrivalDate = member.expectedArrivalAt?.slice(0, 10) || ''
|
||||
return arrivalDate && arrivalDate >= arrivalRange[0] && arrivalDate <= arrivalRange[1]
|
||||
})) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
const pagedCandidates = computed(() => {
|
||||
const start = (candidatePage.value - 1) * candidatePageSize.value
|
||||
return filteredCandidates.value.slice(start, start + candidatePageSize.value)
|
||||
const filteredContainers = computed(() => containers.value)
|
||||
const candidateScopeLabel = computed(() => {
|
||||
const warehouseId = String(appliedQuery.value.warehouseId || '')
|
||||
const warehouse = warehouseOptions.value.find((item) => item.value === warehouseId)?.label || '全部发货仓'
|
||||
const channels = channelOptions.value.filter((item) => selectedChannelCodes.value.includes(item.code)).map((item) => item.name)
|
||||
return `${warehouse} · ${channels.join('、') || '未选择渠道'}`
|
||||
})
|
||||
|
||||
function preferredContainerId() {
|
||||
const visible = filteredContainers.value
|
||||
return visible.find((item) => item.planStatus === 'EDITING')?.id
|
||||
|| visible.find((item) => item.planStatus === 'LOADING')?.id
|
||||
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
|
||||
return visible.find((item) => item.planStatus === 'EDITING')?.bookingId
|
||||
|| visible.find((item) => item.planStatus === 'LOADING')?.bookingId
|
||||
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.bookingId || visible[0]?.bookingId || ''
|
||||
}
|
||||
|
||||
const candidateColumns: DataTableColumn[] = [
|
||||
@@ -282,17 +245,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 | null | undefined) {
|
||||
function displayCabinetNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) {
|
||||
if (!container) return '待生成'
|
||||
const cabinetReference = String(container.cabinetNo || '').trim()
|
||||
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
|
||||
}
|
||||
function hasRealContainerNo(container: ContainerBookingResource | null | undefined) {
|
||||
function hasRealContainerNo(container: ContainerBookingResource | 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 | null | undefined) {
|
||||
function displayActualContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) {
|
||||
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录'
|
||||
}
|
||||
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
|
||||
@@ -306,13 +269,12 @@ function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> =
|
||||
const container = selectedContainer.value
|
||||
if (container) {
|
||||
container.planId = next.id
|
||||
if (syncContainerStatus) container.planStatus = next.status
|
||||
if (syncContainerStatus && next.status !== 'COMPLETED') container.planStatus = next.status
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelectionData() {
|
||||
plan.value = null
|
||||
candidates.value = []
|
||||
noDraft.value = false
|
||||
selectionError.value = null
|
||||
mutationError.value = null
|
||||
@@ -320,25 +282,64 @@ function clearSelectionData() {
|
||||
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
||||
}
|
||||
|
||||
async function loadSelectedData(preserved: Record<string, number> = {}) {
|
||||
function candidateQueryParams(): Parameters<typeof listSmartLoadingCandidates>[0] {
|
||||
const arrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange)
|
||||
? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10))
|
||||
: []
|
||||
const params: Parameters<typeof listSmartLoadingCandidates>[0] = {
|
||||
channelGroupCodes: [...selectedChannelCodes.value],
|
||||
page: candidatePage.value,
|
||||
size: candidatePageSize.value,
|
||||
}
|
||||
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
|
||||
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 (orderStatus) params.orderStatus = orderStatus
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadCandidateData() {
|
||||
if (!selectedChannelCodes.value.length) return
|
||||
const request = ++candidateRequest
|
||||
candidateLoading.value = true
|
||||
candidateError.value = null
|
||||
try {
|
||||
const result = await listSmartLoadingCandidates(candidateQueryParams())
|
||||
if (request !== candidateRequest) return
|
||||
candidates.value = result.records
|
||||
candidateTotal.value = result.total
|
||||
} catch (error) {
|
||||
if (request === candidateRequest) {
|
||||
candidates.value = []
|
||||
candidateTotal.value = 0
|
||||
candidateError.value = getApiErrorDetails(error, '候选装柜明细加载失败')
|
||||
}
|
||||
} finally {
|
||||
if (request === candidateRequest) candidateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedPlan(preserved: Record<string, number> = {}) {
|
||||
const container = selectedContainer.value
|
||||
const request = ++selectionRequest
|
||||
clearSelectionData()
|
||||
if (!container) return
|
||||
if (container.planStatus === 'COMPLETED') return
|
||||
candidatePage.value = 1
|
||||
selectionLoading.value = true
|
||||
try {
|
||||
const [candidateResult, planResult] = await Promise.all([
|
||||
listSmartLoadingCandidates(container.id, container.warehouseId),
|
||||
getSmartLoadingPlanByBooking(container.id).then((value) => ({ value, missing: false })).catch((error) => {
|
||||
const details = getApiErrorDetails(error)
|
||||
if (details.code === 'A0404') return { value: null, missing: true }
|
||||
throw error
|
||||
}),
|
||||
])
|
||||
const planResult = await getSmartLoadingPlanByBooking(container.bookingId).then((value) => ({ value, missing: false })).catch((error) => {
|
||||
const details = getApiErrorDetails(error)
|
||||
if (details.code === 'A0404') return { value: null, missing: true }
|
||||
throw error
|
||||
})
|
||||
if (request !== selectionRequest) return
|
||||
candidates.value = candidateResult
|
||||
if (planResult.value) {
|
||||
acceptPlan(planResult.value, preserved, false)
|
||||
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value)
|
||||
@@ -352,12 +353,30 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function openList(status: 'LOADING' | 'COMPLETED') {
|
||||
const routeName = status === 'LOADING' ? 'loading-plans' : 'loading-lists'
|
||||
const query = selectedContainer.value
|
||||
? { planId: selectedContainer.value.planId || undefined, warehouseId: selectedContainer.value.warehouseId }
|
||||
: undefined
|
||||
void router.push({ name: routeName, query })
|
||||
async function loadContainerData(preserved: Record<string, number> = {}) {
|
||||
if (!selectedChannelCodes.value.length) return
|
||||
const request = ++containerRequest
|
||||
try {
|
||||
const firstPage = await listSmartLoadingWorkbenchContainers({ ...candidateQueryParams(), page: 1, size: 200 })
|
||||
const remainingPages = firstPage.pages > 1
|
||||
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listSmartLoadingWorkbenchContainers({ ...candidateQueryParams(), page: index + 2, size: 200 })))
|
||||
: []
|
||||
if (request !== containerRequest) return
|
||||
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
|
||||
if (!containers.value.some((item) => item.bookingId === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
|
||||
await loadSelectedPlan(preserved)
|
||||
} catch (error) {
|
||||
if (request === containerRequest) {
|
||||
containers.value = []
|
||||
selectedContainerId.value = ''
|
||||
clearSelectionData()
|
||||
selectionError.value = getApiErrorDetails(error, '已选渠道柜次加载失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkbenchData(preserved: Record<string, number> = {}) {
|
||||
await Promise.all([loadCandidateData(), loadContainerData(preserved)])
|
||||
}
|
||||
|
||||
async function loadAll(preserved: Record<string, number> = {}) {
|
||||
@@ -368,7 +387,7 @@ async function loadAll(preserved: Record<string, number> = {}) {
|
||||
const remainingPages = firstPage.pages > 1
|
||||
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
|
||||
: []
|
||||
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
|
||||
containerCatalog.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
|
||||
const availableChannelCodes = new Set(channelOptions.value.map((item) => item.code))
|
||||
selectedChannelCodes.value = selectedChannelCodes.value.filter((code) => availableChannelCodes.has(code))
|
||||
if (!selectedChannelCodes.value.length) {
|
||||
@@ -376,8 +395,7 @@ async function loadAll(preserved: Record<string, number> = {}) {
|
||||
resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === channel.code
|
||||
)))?.code || channelOptions.value[0]?.code || '']
|
||||
}
|
||||
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
|
||||
await loadSelectedData(preserved)
|
||||
await loadWorkbenchData(preserved)
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败')
|
||||
} finally {
|
||||
@@ -399,7 +417,7 @@ async function loadCustomers() {
|
||||
async function selectContainer(containerId: string) {
|
||||
if (selectedContainerId.value === containerId) return
|
||||
selectedContainerId.value = containerId
|
||||
await loadSelectedData()
|
||||
await loadSelectedPlan()
|
||||
}
|
||||
async function selectChannels(codes: string[]) {
|
||||
if (!codes.length) {
|
||||
@@ -407,16 +425,24 @@ async function selectChannels(codes: string[]) {
|
||||
return
|
||||
}
|
||||
selectedChannelCodes.value = codes
|
||||
selectedContainerId.value = preferredContainerId()
|
||||
await loadSelectedData()
|
||||
candidatePage.value = 1
|
||||
await loadWorkbenchData()
|
||||
}
|
||||
async function applyFilters(model: QueryModel) {
|
||||
appliedQuery.value = { ...model }
|
||||
candidatePage.value = 1
|
||||
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
|
||||
selectedContainerId.value = preferredContainerId()
|
||||
await loadSelectedData()
|
||||
}
|
||||
await loadWorkbenchData()
|
||||
}
|
||||
|
||||
async function changeCandidatePage(page: number) {
|
||||
candidatePage.value = page
|
||||
await loadCandidateData()
|
||||
}
|
||||
|
||||
async function changeCandidatePageSize(size: number) {
|
||||
candidatePageSize.value = size
|
||||
candidatePage.value = 1
|
||||
await loadCandidateData()
|
||||
}
|
||||
|
||||
function openAssign(source: object) {
|
||||
@@ -445,13 +471,13 @@ async function assign() {
|
||||
expectedVersion: plan.value!.version,
|
||||
}))
|
||||
assignOpen.value = false
|
||||
await loadSelectedData()
|
||||
await loadWorkbenchData()
|
||||
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次')
|
||||
}, '分配明细失败', { preserveAssign: true })
|
||||
}
|
||||
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
|
||||
return Boolean(candidate.blockReason) || candidate.locked === true
|
||||
|| candidate.sourceCandidates.some((member) => assignedInboundIds.value.has(member.inboundId))
|
||||
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
|
||||
|| candidate.availablePackageCount < 1
|
||||
}
|
||||
async function recommend() {
|
||||
@@ -462,11 +488,11 @@ async function recommend() {
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '智能推荐失败')
|
||||
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
|
||||
await loadSelectedData()
|
||||
await loadSelectedPlan()
|
||||
if (!plan.value) throw error
|
||||
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
|
||||
}
|
||||
await loadSelectedData()
|
||||
await loadWorkbenchData()
|
||||
ElMessage.success('智能推荐已生成')
|
||||
}, '智能推荐失败')
|
||||
}
|
||||
@@ -482,7 +508,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 loadSelectedData()
|
||||
await loadWorkbenchData()
|
||||
ElMessage.success('箱数已调整')
|
||||
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
|
||||
}
|
||||
@@ -499,7 +525,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 loadSelectedData()
|
||||
await loadWorkbenchData()
|
||||
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
|
||||
return
|
||||
}
|
||||
@@ -510,7 +536,7 @@ async function remove(source: object) {
|
||||
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
|
||||
acceptPlan(current)
|
||||
}
|
||||
await loadSelectedData()
|
||||
await loadWorkbenchData()
|
||||
ElMessage.success('该入仓码已从当前柜次移除')
|
||||
}, '移除装柜明细失败', { pickingConflict: true })
|
||||
}
|
||||
@@ -537,7 +563,7 @@ async function removeAll() {
|
||||
acceptPlan(current)
|
||||
}
|
||||
}
|
||||
await loadSelectedData()
|
||||
await loadWorkbenchData()
|
||||
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
|
||||
}, '全部移除失败', { pickingConflict: true })
|
||||
}
|
||||
@@ -582,7 +608,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<el-checkbox-button v-for="channel in channelOptions" :key="channel.code" :label="channel.code" :value="channel.code">{{ channel.name }}</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
<dl class="compact-summary" aria-label="已选渠道装柜统计">
|
||||
<div><dt>候选明细</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
|
||||
<div><dt>候选明细</dt><dd><strong>{{ candidateTotal }}</strong> 条</dd></div>
|
||||
<div><dt>柜子数量</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
|
||||
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
|
||||
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
|
||||
@@ -607,24 +633,29 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
<template v-else>
|
||||
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新当前柜次" @retry="loadSelectedData()">
|
||||
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新工作台" @retry="loadWorkbenchData()">
|
||||
<template #details><ApiErrorMeta :details="mutationError" /></template>
|
||||
</LoadErrorState>
|
||||
<section class="dispatch-grid" aria-label="智能装柜调度台">
|
||||
<section class="dispatch-pane candidate-pane">
|
||||
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ selectedContainer?.warehouseName || '未选择发货仓' }} · {{ resolveSmartLoadingChannelGroup(selectedContainer?.channelCode, selectedContainer?.channelName)?.name || '未选择渠道' }}</small></div></header>
|
||||
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ candidateScopeLabel }}</small></div></header>
|
||||
<LoadErrorState v-if="candidateError" title="候选明细加载失败" :message="candidateError.message" compact @retry="loadCandidateData()">
|
||||
<template #details><ApiErrorMeta :details="candidateError" /></template>
|
||||
</LoadErrorState>
|
||||
<DataTable
|
||||
v-model:page="candidatePage"
|
||||
v-model:page-size="candidatePageSize"
|
||||
:data="pagedCandidates"
|
||||
:page="candidatePage"
|
||||
:page-size="candidatePageSize"
|
||||
:data="displayedCandidates"
|
||||
:columns="candidateColumns"
|
||||
:loading="loading || selectionLoading || mutating"
|
||||
:total="filteredCandidates.length"
|
||||
:loading="loading || candidateLoading || mutating"
|
||||
:total="candidateTotal"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
stripe
|
||||
preference-key="global-smart-loading-candidates"
|
||||
column-settings-mode="hide"
|
||||
:empty-text="selectedContainer ? '当前仓库暂无可装明细' : '暂无可用柜次'"
|
||||
empty-text="当前筛选条件下暂无候选装柜明细"
|
||||
@update:page="changeCandidatePage"
|
||||
@update:page-size="changeCandidatePageSize"
|
||||
>
|
||||
<template #pagination-left="{ total, page, pageCount }"><span v-if="pageCount > 0">共 {{ total }} 条 · {{ page }}/{{ pageCount }} 页</span><span v-else>共 0 条</span></template>
|
||||
<template #actions="{ row }">
|
||||
@@ -652,11 +683,11 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
|
||||
<button
|
||||
v-for="item in filteredContainers"
|
||||
:key="item.id"
|
||||
:key="item.bookingId"
|
||||
type="button"
|
||||
class="container-option"
|
||||
:class="{ selected: item.id === selectedContainerId }"
|
||||
@click="selectContainer(item.id)"
|
||||
:class="{ selected: item.bookingId === selectedContainerId }"
|
||||
@click="selectContainer(item.bookingId)"
|
||||
>
|
||||
<span class="container-option__top">
|
||||
<strong>{{ hasRealContainerNo(item) ? displayActualContainerNo(item) : '模拟柜次' }}</strong>
|
||||
@@ -677,22 +708,16 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<header class="pane-header plan-header">
|
||||
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong></div>
|
||||
<div class="plan-actions">
|
||||
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedPlan()"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
<el-button v-if="canDelete && editable && plan?.items.some((item) => item.canRemove)" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />移除未拣</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<LoadErrorState v-if="selectionError" title="当前柜次加载失败" :message="selectionError.message" compact @retry="loadSelectedData()">
|
||||
<LoadErrorState v-if="selectionError" title="当前柜次加载失败" :message="selectionError.message" compact @retry="loadSelectedPlan()">
|
||||
<template #details><ApiErrorMeta :details="selectionError" /></template>
|
||||
</LoadErrorState>
|
||||
<el-empty v-else-if="!selectedContainer" description="请选择一个货柜" />
|
||||
<section v-else-if="selectedPlanStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成柜次">
|
||||
<el-tag type="success">装柜完成</el-tag>
|
||||
<strong>该柜次已完成装柜</strong>
|
||||
<p>装柜计划、明细和运输信息已永久只读,请在装柜清单查看完整记录。</p>
|
||||
<el-button type="primary" @click="openList('COMPLETED')">查看装柜清单</el-button>
|
||||
</section>
|
||||
<el-empty v-else-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
|
||||
<template v-else-if="plan">
|
||||
<!--
|
||||
|
||||
@@ -32,6 +32,15 @@ const planResponse = {
|
||||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, 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[]) {
|
||||
return {
|
||||
records: records.map((record) => {
|
||||
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
|
||||
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
|
||||
}),
|
||||
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
|
||||
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
|
||||
}
|
||||
|
||||
describe('SmartLoadingWorkbenchView', () => {
|
||||
beforeEach(() => {
|
||||
@@ -39,7 +48,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getContainer).mockResolvedValue(container)
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(planResponse)
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([candidate])
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
|
||||
})
|
||||
|
||||
it('restores the real draft and explains candidate capacity', async () => {
|
||||
@@ -49,7 +58,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
await flushPromises()
|
||||
expect(getContainer).toHaveBeenCalledWith(container.id)
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, '8')
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: '8', page: 1, size: 200 })
|
||||
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
|
||||
expect(wrapper.text()).not.toContain('LP-99')
|
||||
expect(wrapper.text()).toContain('IN-1')
|
||||
@@ -66,7 +75,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: container.warehouseId, page: 1, size: 200 })
|
||||
expect(wrapper.text()).toContain('已下发 PDA,仍可维护未完成的装柜明细')
|
||||
expect(wrapper.text()).toContain('刷新 PDA 明细与版本')
|
||||
expect(wrapper.text()).not.toContain('智能推荐')
|
||||
@@ -118,11 +127,14 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
})
|
||||
|
||||
it('aggregates a two-order group and calls only the atomic whole-group assignment', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
||||
{ ...candidate, inboundId: '201', orderId: '301', inboundCode: 'IN-201', availablePackageCount: 4, availableVolumeCbm: 0.8, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||
{ ...candidate, inboundId: '203', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-203', availablePackageCount: 2, availableVolumeCbm: 0.4, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||
{ ...candidate, accountId: '2', inboundId: '202', orderId: '302', inboundCode: 'IN-202', availablePackageCount: 6, availableVolumeCbm: 1.2, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||
])
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||
...candidate, inboundId: '201', orderId: '301', inboundCode: 'IN-201', availablePackageCount: 12, availableVolumeCbm: 2.4, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null,
|
||||
groupMembers: [
|
||||
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '示例企业', availablePackageCount: 4, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-203', customerName: '示例企业', availablePackageCount: 2, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', customerName: '另一企业', availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
],
|
||||
}]))
|
||||
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(planResponse)
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||
@@ -143,10 +155,11 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
})
|
||||
|
||||
it('shows an exact three-member group block and prevents individual assignment', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(['401', '402', '403'].map((inboundId, index) => ({
|
||||
...candidate, inboundId, orderId: `5${index}`, inboundCode: `IN-${inboundId}`, coLoadGroupId: '9900', groupNo: 'CLG-9900',
|
||||
memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票',
|
||||
})))
|
||||
const members = ['401', '402', '403'].map((inboundId, index) => ({ accountId: `${index + 1}`, inboundId, orderId: `5${index}`, orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, customerName: `客户${index}`, availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }))
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||
...candidate, inboundId: '401', orderId: '50', inboundCode: 'IN-401', availablePackageCount: 18, coLoadGroupId: '9900', groupNo: 'CLG-9900',
|
||||
memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票', groupMembers: members,
|
||||
}]))
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
|
||||
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
||||
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
|
||||
import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -43,7 +44,7 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
|
||||
const editable = computed(() => plan.value?.canEdit === true)
|
||||
const assignable = computed(() => plan.value?.canAssign === true)
|
||||
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
||||
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
|
||||
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
|
||||
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
|
||||
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
|
||||
for (const member of members) {
|
||||
@@ -60,32 +61,19 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
|
||||
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
|
||||
}
|
||||
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||
const rows: SmartLoadingCandidateRow[] = []
|
||||
const groups = new Map<string, SmartLoadingCandidate[]>()
|
||||
for (const candidate of candidates.value) {
|
||||
if (!candidate.coLoadGroupId) {
|
||||
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1 })
|
||||
continue
|
||||
}
|
||||
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
|
||||
}
|
||||
for (const members of groups.values()) {
|
||||
const first = members[0]
|
||||
rows.push({
|
||||
...first,
|
||||
inboundCode: members.map((member) => member.inboundCode).filter(Boolean).join('、'),
|
||||
customerName: [...new Set(members.map((member) => member.customerName).filter(Boolean))].join('、'),
|
||||
goodsSummary: [...new Set(members.map((member) => member.goodsSummary).filter(Boolean))].join('、'),
|
||||
availablePackageCount: first.groupTotalBoxes || members.reduce((sum, member) => sum + member.availablePackageCount, 0),
|
||||
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
|
||||
sourceCandidates: members,
|
||||
return candidates.value.map((candidate) => {
|
||||
const members: Array<{ inboundId: string; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
|
||||
? candidate.groupMembers
|
||||
: [candidate]
|
||||
return {
|
||||
...candidate,
|
||||
memberInboundIds: members.map((member) => member.inboundId),
|
||||
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
|
||||
memberOrderSummaries: memberOrderSummaries(members),
|
||||
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
|
||||
inboundFragmentCount: Number(first.memberCount || members.length),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
inboundFragmentCount: Number(candidate.memberCount || members.length),
|
||||
}
|
||||
})
|
||||
})
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
@@ -207,8 +195,9 @@ async function load() {
|
||||
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
|
||||
else throw error
|
||||
}
|
||||
candidates.value = plan.value?.canAssign
|
||||
? await listSmartLoadingCandidates(container.value.id, container.value.warehouseId)
|
||||
const channelGroup = resolveSmartLoadingChannelGroup(container.value.channelCode, container.value.channelName)
|
||||
candidates.value = plan.value?.canAssign && channelGroup
|
||||
? (await listSmartLoadingCandidates({ channelGroupCodes: [channelGroup.code], warehouseId: container.value.warehouseId, page: 1, size: 200 })).records
|
||||
: []
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -245,7 +234,7 @@ async function assign() {
|
||||
}
|
||||
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
|
||||
return Boolean(candidate.blockReason) || candidate.locked === true
|
||||
|| candidate.sourceCandidates.some((member) => assignedInboundIds.value.has(member.inboundId))
|
||||
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
|
||||
|| candidate.availablePackageCount < 1
|
||||
}
|
||||
async function recommend() {
|
||||
|
||||
@@ -231,6 +231,7 @@ export interface ContainerSaveRequest {
|
||||
|
||||
export interface SmartLoadingCandidate {
|
||||
accountId: string
|
||||
customerId: string
|
||||
inboundId: string
|
||||
orderId: string
|
||||
orderNo: string | null
|
||||
@@ -256,6 +257,63 @@ export interface SmartLoadingCandidate {
|
||||
groupTotalBoxes: number | null
|
||||
locked: boolean | null
|
||||
blockReason: string | null
|
||||
groupMembers: SmartLoadingCandidateGroupMember[]
|
||||
}
|
||||
|
||||
export interface SmartLoadingCandidateGroupMember {
|
||||
accountId: string
|
||||
inboundId: string
|
||||
orderId: string
|
||||
orderNo: string
|
||||
inboundCode: string
|
||||
customerName: string
|
||||
availablePackageCount: number
|
||||
orderStatusSnapshot: string | null
|
||||
expectedArrivalAt: string | null
|
||||
}
|
||||
|
||||
export interface SmartLoadingCandidateQuery {
|
||||
channelGroupCodes: string[]
|
||||
warehouseId?: string
|
||||
customerId?: string
|
||||
inboundCode?: string
|
||||
expectedArrivalStart?: string
|
||||
expectedArrivalEnd?: string
|
||||
orderStatus?: 'PENDING_REVIEW' | 'WAIT_MEASURE' | 'WAIT_SPLIT' | 'LOADING'
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface SmartLoadingCandidatePage {
|
||||
records: SmartLoadingCandidate[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface SmartLoadingWorkbenchContainer {
|
||||
bookingId: string
|
||||
bookingNo: string
|
||||
cabinetNo: string
|
||||
containerNo: string | null
|
||||
warehouseId: string
|
||||
warehouseName: string
|
||||
channelCode: string
|
||||
channelName: string
|
||||
containerType: string
|
||||
planId: string | null
|
||||
planNo: string | null
|
||||
planStatus: Exclude<ContainerPlanStatus, 'COMPLETED'>
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface SmartLoadingWorkbenchContainerPage {
|
||||
records: SmartLoadingWorkbenchContainer[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface SmartLoadingItem {
|
||||
|
||||
@@ -448,28 +448,28 @@ const queryFields = computed(() => showAdvancedFilters.value
|
||||
: primaryQueryFields.value)
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
|
||||
...(can('platform:order:approve') || can('platform:firstleg:smart-loading:assign') || can('platform:order:export') ? [{
|
||||
key: 'selection', type: 'selection', width: 48, fixed: 'left',
|
||||
selectable: () => !batchApprovalSaving.value && !coLoadSaving.value && !exportSubmitting.value,
|
||||
} satisfies DataTableColumn] : []),
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 260, slotName: 'inboundCode' },
|
||||
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
|
||||
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 150, slotName: 'field' },
|
||||
{ key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '目的仓', minWidth: 150, slotName: 'field' },
|
||||
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 180, slotName: 'channel' },
|
||||
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 100, slotName: 'field' },
|
||||
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到货时间', minWidth: 220, slotName: 'expectedArrival' },
|
||||
{ key: 'estimatedVolumeCbm', prop: 'estimatedVolumeCbm', label: '预估体积(m³)', minWidth: 180, slotName: 'estimatedVolume' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode' },
|
||||
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 120, slotName: 'mainStatus' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'field' },
|
||||
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 100, slotName: 'field' },
|
||||
{ key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '目的仓', minWidth: 100, slotName: 'field' },
|
||||
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 140, slotName: 'channel' },
|
||||
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 80, slotName: 'field' },
|
||||
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到货时间', minWidth: 170, slotName: 'expectedArrival' },
|
||||
{ key: 'estimatedVolumeCbm', prop: 'estimatedVolumeCbm', label: '预估体积(m³)', minWidth: 120, slotName: 'estimatedVolume' },
|
||||
{ key: 'expectedVolumeCbm', prop: 'expectedVolumeCbm', label: '下单体积(m³)', minWidth: 130, slotName: 'field' },
|
||||
{ key: 'actualVolumeCbm', prop: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 130, slotName: 'field' },
|
||||
{ key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托数', minWidth: 100, slotName: 'field' },
|
||||
{ key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间', minWidth: 160, slotName: 'field' },
|
||||
{ key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托数', minWidth: 90, slotName: 'field' },
|
||||
{ key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间', minWidth: 140, slotName: 'field' },
|
||||
{ key: 'locationCodes', prop: 'locationCodes', label: '库位位置', minWidth: 160, slotName: 'field', defaultVisible: false },
|
||||
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'measurementReviewStatus' },
|
||||
{ key: 'actualGrossWeightKg', prop: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 130, slotName: 'field' },
|
||||
{ key: 'totalNetWeightKg', prop: 'totalNetWeightKg', label: '货物净重(kg)', minWidth: 130, slotName: 'field' },
|
||||
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 150, slotName: 'measurementReviewStatus' },
|
||||
{ key: 'actualGrossWeightKg', prop: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 120, slotName: 'field' },
|
||||
{ key: 'totalNetWeightKg', prop: 'totalNetWeightKg', label: '货物净重(kg)', minWidth: 120, slotName: 'field' },
|
||||
{ key: 'chargeableWeightKg', prop: 'chargeableWeightKg', label: '计费毛重', minWidth: 120, slotName: 'field' },
|
||||
{ key: 'chargeableVolumeCbm', prop: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 130, slotName: 'field' },
|
||||
{ key: 'expectedShipDate', prop: 'expectedShipDate', label: '预计出运日期', minWidth: 150, slotName: 'field' },
|
||||
@@ -2167,12 +2167,13 @@ onBeforeUnmount(() => {
|
||||
<template #expectedArrival="{ row }">
|
||||
<div class="planning-cell">
|
||||
<el-date-picker
|
||||
style="width: 100%;"
|
||||
v-if="can('platform:order:declaration:edit')"
|
||||
v-model="planningDraft(row as UnifiedOrderResource).expectedArrivalAt"
|
||||
class="planning-arrival-input"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
format="YYYY-MM-DD"
|
||||
clearable
|
||||
:disabled="planningDraft(row as UnifiedOrderResource).saving"
|
||||
placeholder="选择预计到货时间"
|
||||
|
||||
Reference in New Issue
Block a user