Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test

This commit is contained in:
2026-09-04 11:50:35 +08:00
16 changed files with 326 additions and 218 deletions
+1 -1
View File
@@ -50,7 +50,7 @@
| 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | | 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关逐资料完成,全买单柜使用 `complete-all-buy` | | 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关逐资料完成,全买单柜使用 `complete-all-buy` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers` 和计划分配接口;渠道分组选项与服务端映射必须一致,同柜不可拆组作为单个候选卡片整组分配,前端不得拆成员分页或自行拼组 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 三期订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | | 三期订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | | 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { import {
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeSmartLoadingGroup, removeSmartLoadingItem, listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
getCustomerContainerTransitSummary, getCustomerContainerTransitSummary,
@@ -66,9 +66,12 @@ describe('platform container loading API', () => {
it('normalizes every long id in candidates and plans', async () => { it('normalizes every long id in candidates and plans', async () => {
vi.mocked(http.get) vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: container }) .mockResolvedValueOnce({ code: '200', data: container })
.mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, customerId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } }) .mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } })
expect((await getContainer('9')).id).toBe(String(container.id)) expect((await getContainer('9')).id).toBe(String(container.id))
expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', page: 1, size: 20 })).toMatchObject({ records: [{ accountId: '1', customerId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }], total: 1 }) expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', orderStatuses: ['PENDING_REVIEW', 'WAIT_MEASURE'], page: 1, size: 20 })).toMatchObject({ records: [{ accountId: '1', customerId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }], total: 1 })
const candidateParams = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
expect(candidateParams.getAll('orderStatuses')).toEqual(['PENDING_REVIEW', 'WAIT_MEASURE'])
expect(candidateParams.has('orderStatus')).toBe(false)
const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
expect(params.getAll('channelGroupCodes')).toEqual(['EXX', 'HEDE']) expect(params.getAll('channelGroupCodes')).toEqual(['EXX', 'HEDE'])
expect(params.get('warehouseId')).toBe('5') expect(params.get('warehouseId')).toBe('5')
@@ -112,7 +115,7 @@ describe('platform container loading API', () => {
page: '1', size: '200', total: '1', pages: '1', page: '1', size: '200', total: '1', pages: '1',
} }) } })
const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', createdStart: '2026-09-01', createdEnd: '2026-09-03', orderStatus: 'WAIT_SPLIT', page: 1, size: 200 }) const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', createdStart: '2026-09-01', createdEnd: '2026-09-03', orderStatuses: ['WAIT_SPLIT'], page: 1, size: 200 })
expect(result).toMatchObject({ records: [{ bookingId: '9007199254740993', warehouseId: '8', planId: '9007199254740994', version: 2 }], total: 1 }) 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 const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
@@ -121,7 +124,8 @@ describe('platform container loading API', () => {
expect(params.get('inboundCode')).toBe('IN-1') expect(params.get('inboundCode')).toBe('IN-1')
expect(params.get('createdStart')).toBe('2026-09-01') expect(params.get('createdStart')).toBe('2026-09-01')
expect(params.get('createdEnd')).toBe('2026-09-03') expect(params.get('createdEnd')).toBe('2026-09-03')
expect(params.get('orderStatus')).toBe('WAIT_SPLIT') expect(params.has('orderStatus')).toBe(false)
expect(params.has('orderStatuses')).toBe(false)
expect(params.has('bookingId')).toBe(false) expect(params.has('bookingId')).toBe(false)
}) })
@@ -140,6 +144,17 @@ describe('platform container loading API', () => {
) )
}) })
it('uses one endpoint to remove all unpicked plan items', async () => {
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
await removeAllUnpickedSmartLoadingItems('10', 6)
expect(http.delete).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/unpicked-items',
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-unpicked-') } },
)
})
it('uses stable container id and versioned clearance document actions', async () => { it('uses stable container id and versioned clearance document actions', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } }) vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } }) vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
@@ -119,6 +119,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
blockedReason: raw.blockedReason || null, blockedReason: raw.blockedReason || null,
pdaTaskId: optionalId(raw.pdaTaskId), pdaTaskId: optionalId(raw.pdaTaskId),
pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion), pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion),
coLoadGroups: (raw.coLoadGroups || []).map((group) => ({
...group,
groupId: id(group.groupId), lockedPlanId: optionalId(group.lockedPlanId),
memberOrderCount: numberValue(group.memberOrderCount),
inboundFragmentCount: numberValue(group.inboundFragmentCount),
fullPackageCount: numberValue(group.fullPackageCount),
memberInboundIds: (group.memberInboundIds || []).map(id),
})),
items: (raw.items || []).map(item), items: (raw.items || []).map(item),
} }
} }
@@ -228,43 +236,49 @@ export async function completeContainerClearance(containerId: string, expectedVe
return document(unwrapApiResponse(response)) return document(unwrapApiResponse(response))
} }
function smartLoadingQueryParams(query: SmartLoadingCandidateQuery) { function smartLoadingQueryParams(query: SmartLoadingCandidateQuery, includeOrderStatuses = false) {
const params = new URLSearchParams() const params = new URLSearchParams()
for (const channelGroupCode of query.channelGroupCodes) params.append('channelGroupCodes', channelGroupCode) for (const channelGroupCode of query.channelGroupCodes) params.append('channelGroupCodes', channelGroupCode)
if (includeOrderStatuses) {
for (const orderStatus of query.orderStatuses || []) params.append('orderStatuses', orderStatus)
}
const optionalParams: Array<[string, string | number | undefined]> = [ const optionalParams: Array<[string, string | number | undefined]> = [
['warehouseId', query.warehouseId], ['customerId', query.customerId], ['inboundCode', query.inboundCode], ['warehouseId', query.warehouseId], ['customerId', query.customerId], ['inboundCode', query.inboundCode],
['createdStart', query.createdStart], ['createdEnd', query.createdEnd], ['createdStart', query.createdStart], ['createdEnd', query.createdEnd],
['orderStatus', query.orderStatus], ['page', query.page], ['size', query.size], ['page', query.page], ['size', query.size],
] ]
for (const [key, value] of optionalParams) if (value !== undefined && value !== '') params.append(key, String(value)) for (const [key, value] of optionalParams) if (value !== undefined && value !== '') params.append(key, String(value))
return params return params
} }
export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQuery) { export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQuery) {
const params = smartLoadingQueryParams(query) const params = smartLoadingQueryParams(query, true)
const response = await http.get<ApiEnvelope<SmartLoadingCandidatePage>, ApiEnvelope<SmartLoadingCandidatePage>>(`${smartLoadingPath}/inbound-candidates`, { params }) const response = await http.get<ApiEnvelope<SmartLoadingCandidatePage>, ApiEnvelope<SmartLoadingCandidatePage>>(`${smartLoadingPath}/inbound-candidates`, { params })
const result = unwrapApiResponse(response) const result = unwrapApiResponse(response)
return { return {
...result, ...result,
page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size),
total: numberValue(result.total), pages: numberValue(result.pages), total: numberValue(result.total), pages: numberValue(result.pages),
records: result.records.map((raw) => ({ records: result.records.map((raw) => {
...raw, accountId: id(raw.accountId), customerId: id(raw.customerId), inboundId: optionalId(raw.inboundId), const accountId = id(raw.accountId)
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), return {
coLoadGroupId: optionalId(raw.coLoadGroupId), ...raw, accountId, customerId: accountId, inboundId: optionalId(raw.inboundId),
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes), coLoadGroupId: optionalId(raw.coLoadGroupId),
locked: raw.locked == null ? null : Boolean(raw.locked), memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
availablePackageCount: numberValue(raw.availablePackageCount), groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg), locked: raw.locked == null ? null : Boolean(raw.locked),
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg), availablePackageCount: numberValue(raw.availablePackageCount),
averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm), unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm), unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
groupMembers: raw.groupMembers.map((member) => ({ averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm),
...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId), availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm),
availablePackageCount: numberValue(member.availablePackageCount), groupMembers: raw.groupMembers.map((member) => ({
})), ...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
})), availablePackageCount: numberValue(member.availablePackageCount),
})),
}
}),
} }
} }
@@ -277,6 +291,8 @@ export async function listSmartLoadingWorkbenchContainers(query: SmartLoadingCan
total: numberValue(result.total), pages: numberValue(result.pages), total: numberValue(result.total), pages: numberValue(result.pages),
records: result.records.map((raw) => ({ records: result.records.map((raw) => ({
...raw, bookingId: id(raw.bookingId), warehouseId: id(raw.warehouseId), planId: optionalId(raw.planId), ...raw, bookingId: id(raw.bookingId), warehouseId: id(raw.warehouseId), planId: optionalId(raw.planId),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
version: numberValue(raw.version), version: numberValue(raw.version),
})), })),
} }
@@ -335,3 +351,11 @@ export async function removeSmartLoadingGroup(planId: string, groupId: string, e
return plan(unwrapApiResponse(response)) return plan(unwrapApiResponse(response))
} }
export async function removeAllUnpickedSmartLoadingItems(planId: string, expectedPlanVersion: number) {
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/unpicked-items`,
{ data: { expectedPlanVersion, operationId: operationId('remove-unpicked') } },
)
return plan(unwrapApiResponse(response))
}
@@ -8,7 +8,7 @@ import SmartLoadingDialog from './SmartLoadingDialog.vue'
describe('SmartLoadingDialog', () => { describe('SmartLoadingDialog', () => {
afterEach(() => { document.body.innerHTML = '' }) afterEach(() => { document.body.innerHTML = '' })
it('expands to the viewport and can return to the normal dialog size', async () => { it('opens fullscreen by default, can return to normal size, and reopens fullscreen', async () => {
const wrapper = mount(SmartLoadingDialog, { const wrapper = mount(SmartLoadingDialog, {
attachTo: document.body, attachTo: document.body,
props: { modelValue: true }, props: { modelValue: true },
@@ -21,19 +21,30 @@ describe('SmartLoadingDialog', () => {
expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false) expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false)
expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true) expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true)
const expand = wrapper.get('button[aria-label="展开全屏"]')
await expand.trigger('click')
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull() expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true) expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
await wrapper.get('button[aria-label="退出全屏"]').trigger('click') await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull() expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull()
expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true) expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true)
await wrapper.setProps({ modelValue: false })
await flushPromises()
await wrapper.setProps({ modelValue: true })
await flushPromises()
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
}) })
it('keeps the outer dialog fixed so only workbench data panes scroll', () => { it('keeps the outer dialog fixed so only workbench data panes scroll', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/SmartLoadingDialog.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/SmartLoadingDialog.vue'), 'utf8')
expect(source).toContain('fullscreenable')
expect(source).toContain('default-fullscreen')
expect(source).not.toContain('Maximize2')
expect(source).not.toContain('const fullscreen')
expect(source).toContain('.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)')
expect(source).toContain('height: 94vh; max-height: 94vh;') expect(source).toContain('height: 94vh; max-height: 94vh;')
expect(source).not.toContain('.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw)')
expect(source).toContain('flex: 1; padding: 0; overflow: hidden;') expect(source).toContain('flex: 1; padding: 0; overflow: hidden;')
expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;') expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;')
}) })
@@ -1,17 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Maximize2, Minimize2 } from '@lucide/vue'
import { ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue' import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const fullscreen = ref(false)
watch(open, (value) => {
if (!value) {
fullscreen.value = false
}
})
</script> </script>
<template> <template>
@@ -20,38 +11,21 @@ watch(open, (value) => {
class="smart-loading-dialog" class="smart-loading-dialog"
title="智能装柜" title="智能装柜"
width="min(1600px, 98vw)" width="min(1600px, 98vw)"
:fullscreen="fullscreen" fullscreenable
default-fullscreen
cancel-text="关闭" cancel-text="关闭"
:show-confirm="false" :show-confirm="false"
destroy-on-close destroy-on-close
> >
<template #header>
<div class="smart-loading-dialog__header">
<div class="smart-loading-dialog__heading">
<span>智能装柜</span>
</div>
<el-tooltip :content="fullscreen ? '退出全屏' : '展开全屏'" placement="bottom">
<el-button text :aria-label="fullscreen ? '退出全屏' : '展开全屏'" @click="fullscreen = !fullscreen">
<Minimize2 v-if="fullscreen" :size="18" />
<Maximize2 v-else :size="18" />
</el-button>
</el-tooltip>
</div>
</template>
<GlobalSmartLoadingView embedded /> <GlobalSmartLoadingView embedded />
</AppDialog> </AppDialog>
</template> </template>
<style scoped> <style scoped>
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; } :global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
:global(.smart-loading-dialog.app-dialog.el-dialog.is-fullscreen) { width: 100%; height: 100vh; max-height: 100vh; margin: 0 !important; border-radius: 0; }
:global(.smart-loading-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; } :global(.smart-loading-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
:global(.smart-loading-dialog .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; } :global(.smart-loading-dialog .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
.smart-loading-dialog__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; color: var(--cc-text); font-size: 18px; font-weight: 600; line-height: 24px; }
.smart-loading-dialog__heading { display: flex; min-width: 0; align-items: center; gap: 24px; }
.smart-loading-dialog__heading > span { flex: 0 0 auto; }
.smart-loading-dialog__header :deep(.el-button) { flex: 0 0 auto; margin-right: 24px; color: var(--cc-muted); }
@media (max-width: 720px) { @media (max-width: 720px) {
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; } :global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
} }
</style> </style>
@@ -18,6 +18,7 @@ import {
listSmartLoadingCandidates, listSmartLoadingCandidates,
listSmartLoadingWorkbenchContainers, listSmartLoadingWorkbenchContainers,
recommendSmartLoading, recommendSmartLoading,
removeAllUnpickedSmartLoadingItems,
removeSmartLoadingGroup, removeSmartLoadingGroup,
removeSmartLoadingItem, removeSmartLoadingItem,
} from '../api/platformContainerLoading' } from '../api/platformContainerLoading'
@@ -47,9 +48,16 @@ vi.mock('../api/platformContainerLoading', () => ({
adjustSmartLoadingItem: vi.fn(), adjustSmartLoadingItem: vi.fn(),
removeSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(),
removeSmartLoadingGroup: vi.fn(), removeSmartLoadingGroup: vi.fn(),
removeAllUnpickedSmartLoadingItems: vi.fn(),
pushSmartLoadingPreload: vi.fn(), pushSmartLoadingPreload: vi.fn(),
})) }))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) })) vi.mock('@/modules/warehouses', () => ({
listAllPlatformWarehouses: vi.fn().mockResolvedValue([
{ id: '8', warehouseName: '义乌仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' },
{ id: '18', warehouseName: '东莞仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' },
]),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) }))
const container = { const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
@@ -64,6 +72,7 @@ const plan = {
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2, id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }], items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
} }
const candidate = { const candidate = {
@@ -89,7 +98,8 @@ function containerPage(records: ContainerBookingResource[] = [container]) {
records: records.map((item) => ({ records: records.map((item) => ({
bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo, bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelCode: item.channelCode || '', channelName: item.channelName || '', warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelCode: item.channelCode || '', channelName: item.channelName || '',
containerType: item.containerType, planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version, containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg,
planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version,
})), })),
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0, page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
} as Awaited<ReturnType<typeof listSmartLoadingWorkbenchContainers>> } as Awaited<ReturnType<typeof listSmartLoadingWorkbenchContainers>>
@@ -102,10 +112,18 @@ function mockContainerSearch(records: ContainerBookingResource[]) {
} }
describe('GlobalSmartLoadingView', () => { describe('GlobalSmartLoadingView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%; }')
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
})
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
mockContainerSearch([container]) mockContainerSearch([container])
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate])) vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
@@ -115,7 +133,6 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 }) expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
expect(wrapper.text()).toContain('候选装柜明细') expect(wrapper.text()).toContain('候选装柜明细')
@@ -145,13 +162,12 @@ describe('GlobalSmartLoadingView', () => {
total: 1, total: 1,
pages: 1, pages: 1,
}) })
mockContainerSearch([{ ...container, shippingCompanyId: '32', shippingCompanyName: 'EXX', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }]) mockContainerSearch([container])
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] } }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('EXX') expect(wrapper.text()).toContain('美森正班')
expect(wrapper.text()).not.toContain('纽约EXX海铁') expect(wrapper.text()).not.toContain('美森正班海卡包税')
expect(wrapper.text()).not.toContain('缺少渠道快照') expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
}) })
@@ -280,7 +296,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).toContain('MSCU-DG') expect(wrapper.text()).toContain('MSCU-DG')
const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatus: '' }) wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('MSCU-DG') expect(wrapper.text()).toContain('MSCU-DG')
@@ -307,7 +323,7 @@ describe('GlobalSmartLoadingView', () => {
await flushPromises() await flushPromises()
const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatus: '' }) wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] })
await flushPromises() await flushPromises()
expect(wrapper.find('.channel-tags .el-checkbox-button.is-checked').text()).toBe('美森正班') expect(wrapper.find('.channel-tags .el-checkbox-button.is-checked').text()).toBe('美森正班')
@@ -338,7 +354,7 @@ describe('GlobalSmartLoadingView', () => {
const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length
wrapper.findComponent(QueryToolbar).vm.$emit('search', { wrapper.findComponent(QueryToolbar).vm.$emit('search', {
warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', createdRange: [], orderStatus: '', warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', createdRange: [], orderStatuses: [],
}) })
await flushPromises() await flushPromises()
@@ -363,7 +379,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain(': candidate.availablePackageCount') expect(source).toContain(': candidate.availablePackageCount')
}) })
it('filters candidates by expected arrival range and order status', async () => { it('filters candidates by creation range and multiple order statuses', async () => {
vi.mocked(listSmartLoadingCandidates) vi.mocked(listSmartLoadingCandidates)
.mockResolvedValueOnce(candidatePage([ .mockResolvedValueOnce(candidatePage([
{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' }, { ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' },
@@ -376,7 +392,7 @@ describe('GlobalSmartLoadingView', () => {
const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length
wrapper.getComponent(QueryToolbar).vm.$emit('search', { wrapper.getComponent(QueryToolbar).vm.$emit('search', {
customerId: '', inboundCode: '', orderStatus: 'WAIT_SPLIT', createdRange: ['2026-09-10', '2026-09-12'], customerId: '', inboundCode: '', orderStatuses: ['WAIT_SPLIT', 'LOADING'], createdRange: ['2026-09-10', '2026-09-12'],
}) })
await flushPromises() await flushPromises()
@@ -384,7 +400,7 @@ describe('GlobalSmartLoadingView', () => {
expect(candidates).toContain('IN-MATCH') expect(candidates).toContain('IN-MATCH')
expect(candidates).not.toContain('IN-WRONG-STATUS') expect(candidates).not.toContain('IN-WRONG-STATUS')
expect(candidates).not.toContain('IN-WRONG-DATE') expect(candidates).not.toContain('IN-WRONG-DATE')
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], createdStart: '2026-09-10', createdEnd: '2026-09-12', orderStatus: 'WAIT_SPLIT', page: 1, size: 20 }) expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], createdStart: '2026-09-10', createdEnd: '2026-09-12', orderStatuses: ['WAIT_SPLIT', 'LOADING'], page: 1, size: 20 })
expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount) expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount)
expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 200 }) expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 200 })
}) })
@@ -438,6 +454,8 @@ describe('GlobalSmartLoadingView', () => {
await buttons[0].trigger('click') await buttons[0].trigger('click')
await flushPromises() await flushPromises()
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除') expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班海卡包税')
expect(document.body.textContent).not.toContain('本次分配箱数') expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click() ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises() await flushPromises()
@@ -450,10 +468,15 @@ describe('GlobalSmartLoadingView', () => {
it('allows a pending-review order to join through the provisional assignment command', async () => { it('allows a pending-review order to join through the provisional assignment command', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, ...candidate,
customerId: '1', customerId: '',
inboundId: null, inboundId: null,
orderStatusSnapshot: 'PENDING_REVIEW', orderStatusSnapshot: 'PENDING_REVIEW',
inboundCode: 'EXXHK2609020361', inboundCode: 'EXXHK2609020361',
channelCode: 'SLOW',
channelName: '慢船',
unitGrossWeightKg: null,
averageUnitVolumeCbm: null,
availableVolumeCbm: null,
blockReason: null, blockReason: null,
}])) }]))
vi.mocked(assignPendingReviewSmartLoadingOrder).mockResolvedValue(plan) vi.mocked(assignPendingReviewSmartLoadingOrder).mockResolvedValue(plan)
@@ -466,6 +489,12 @@ describe('GlobalSmartLoadingView', () => {
expect(add.classes()).not.toContain('is-disabled') expect(add.classes()).not.toContain('is-disabled')
await add.trigger('click') await add.trigger('click')
await flushPromises() await flushPromises()
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
expect(document.body.textContent).toContain('平均单箱体积 待测量')
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
expect(document.body.textContent).toContain('订单渠道:慢船')
expect(document.body.textContent).toContain('这是人工排柜,确认后仍会加入')
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
;([...document.body.querySelectorAll('button')] ;([...document.body.querySelectorAll('button')]
.find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click() .find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click()
await flushPromises() await flushPromises()
@@ -509,8 +538,8 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('0.22%') expect(wrapper.text()).toContain('0.16%')
expect(wrapper.text()).toContain('0.04%') expect(wrapper.text()).toContain('0.03%')
expect(wrapper.text()).not.toContain('0.169999999') expect(wrapper.text()).not.toContain('0.169999999')
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0) expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
}) })
@@ -520,16 +549,19 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }> const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }>; props?: Record<string, unknown> }>
expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'orderStatus']) expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'orderStatuses'])
expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([{ label: '义乌仓', value: '8' }]) expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([
expect(fields.find((field) => field.key === 'orderStatus')?.options).toEqual([ { label: '义乌仓', value: '8' },
{ value: '', label: '全部' }, { label: '东莞仓', value: '18' },
])
expect(fields.find((field) => field.key === 'orderStatuses')?.options).toEqual([
{ value: 'PENDING_REVIEW', label: '待审核' }, { value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' }, { value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'WAIT_SPLIT', label: '待分柜' }, { value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' }, { value: 'LOADING', label: '装柜中' },
]) ])
expect(fields.find((field) => field.key === 'orderStatuses')?.props).toMatchObject({ multiple: true, collapseTags: true, maxCollapseTags: 2 })
expect(source).not.toContain('目的仓') expect(source).not.toContain('目的仓')
expect(wrapper.text()).not.toContain('税率') expect(wrapper.text()).not.toContain('税率')
@@ -610,7 +642,7 @@ describe('GlobalSmartLoadingView', () => {
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled() expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('CB-3') expect(wrapper.text()).not.toContain('CB-3')
expect(wrapper.text()).toContain('当前柜次已完成装柜,不可继续编辑') expect(wrapper.text()).toContain('暂无符合条件的货柜')
expect(wrapper.text()).not.toContain('缺少渠道快照') expect(wrapper.text()).not.toContain('缺少渠道快照')
}) })
@@ -661,7 +693,7 @@ describe('GlobalSmartLoadingView', () => {
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2) expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3) expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(3) expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
expect(warningMessage).not.toHaveBeenCalled() expect(warningMessage).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('装柜操作失败') expect(wrapper.text()).not.toContain('装柜操作失败')
expect(wrapper.text()).not.toContain('request-1') expect(wrapper.text()).not.toContain('request-1')
@@ -692,13 +724,12 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).toContain('trace-remove-1') expect(wrapper.text()).toContain('trace-remove-1')
}) })
it('removes every item with the latest returned plan version', async () => { it('removes every unpicked item with one bulk request', async () => {
const secondItem = { ...plan.items[0], id: '102', inboundDetailId: '4', inboundCode: 'IN-2' } const secondItem = { ...plan.items[0], id: '102', inboundDetailId: '4', inboundCode: 'IN-2' }
const initialPlan = { ...plan, items: [plan.items[0], secondItem] } const initialPlan = { ...plan, items: [plan.items[0], secondItem] }
const afterFirst = { ...plan, version: 3, items: [secondItem] }
const emptyPlan = { ...plan, version: 4, items: [] } const emptyPlan = { ...plan, version: 4, items: [] }
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(initialPlan).mockResolvedValue(emptyPlan) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(initialPlan).mockResolvedValue(emptyPlan)
vi.mocked(removeSmartLoadingItem).mockResolvedValueOnce(afterFirst).mockResolvedValueOnce(emptyPlan) vi.mocked(removeAllUnpickedSmartLoadingItems).mockResolvedValueOnce(emptyPlan)
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
@@ -708,8 +739,9 @@ describe('GlobalSmartLoadingView', () => {
await removeAllButton!.trigger('click') await removeAllButton!.trigger('click')
await flushPromises() await flushPromises()
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1) expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledTimes(1)
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondItem.id, 3, 1) expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledWith(plan.id, 2)
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('当前柜次暂无装柜明细') expect(wrapper.text()).toContain('当前柜次暂无装柜明细')
}) })
@@ -4,6 +4,7 @@ import { Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
@@ -18,15 +19,14 @@ import {
assignSmartLoadingGroup, assignSmartLoadingGroup,
assignSmartLoadingItem, assignSmartLoadingItem,
getSmartLoadingPlanByBooking, getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates, listSmartLoadingCandidates,
listSmartLoadingWorkbenchContainers, listSmartLoadingWorkbenchContainers,
recommendSmartLoading, recommendSmartLoading,
removeAllUnpickedSmartLoadingItems,
removeSmartLoadingGroup, removeSmartLoadingGroup,
removeSmartLoadingItem, removeSmartLoadingItem,
} from '../api/platformContainerLoading' } from '../api/platformContainerLoading'
import type { import type {
ContainerBookingResource,
SmartLoadingCandidate, SmartLoadingCandidate,
SmartLoadingPlan, SmartLoadingPlan,
SmartLoadingWorkbenchContainer, SmartLoadingWorkbenchContainer,
@@ -42,7 +42,7 @@ const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: fa
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const containers = ref<SmartLoadingWorkbenchContainer[]>([]) const containers = ref<SmartLoadingWorkbenchContainer[]>([])
const containerCatalog = ref<ContainerBookingResource[]>([]) const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const customers = ref<PlatformTenantResource[]>([]) const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false) const customersLoading = ref(false)
const selectedContainerId = ref('') const selectedContainerId = ref('')
@@ -63,8 +63,8 @@ const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 }) const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({}) const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>()) const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], orderStatus: '' }) const query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] })
const appliedQuery = ref<QueryModel>({ ...query.value }) const appliedQuery = ref<QueryModel>({ ...query.value, orderStatuses: [] })
const candidatePage = ref(1) const candidatePage = ref(1)
const candidatePageSize = ref(20) const candidatePageSize = ref(20)
const candidateTotal = ref(0) const candidateTotal = ref(0)
@@ -75,8 +75,13 @@ let containerRequest = 0
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign')) const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete')) const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const selectedContainer = computed(() => containers.value.find((item) => item.bookingId === selectedContainerId.value) || null) 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 crossChannelAssignment = computed(() => Boolean(
const completedContainerCount = computed(() => containerCatalog.value.filter((item) => item.planStatus === 'COMPLETED').length) selectedCandidate.value?.channelCode
&& selectedContainer.value?.channelCode
&& selectedCandidate.value.channelCode !== selectedContainer.value.channelCode,
))
const orchestratableContainers = computed(() => containers.value)
const completedContainerCount = computed(() => 0)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length) const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => ( const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName) item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
@@ -86,17 +91,12 @@ const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.va
)).length) )).length)
const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS) const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS)
const warehouseOptions = computed(() => { const warehouseOptions = computed(() => {
const options = new Map<string, string>() return warehouses.value.map((warehouse) => ({ value: String(warehouse.id), label: warehouse.warehouseName }))
for (const item of orchestratableContainers.value) {
if (item.warehouseId && !options.has(item.warehouseId)) options.set(item.warehouseId, item.warehouseName?.trim() || '未命名发货仓')
}
return [...options].map(([value, label]) => ({ value, label }))
}) })
const editable = computed(() => plan.value?.canEdit === true) const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true) const assignable = computed(() => plan.value?.canAssign === true)
const selectedContainerResource = computed(() => containerCatalog.value.find((item) => item.id === selectedContainerId.value) || null)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId))) const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number; totalVolumeCbm: number } type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) { function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>() const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) { for (const member of members) {
@@ -113,11 +113,11 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段` : `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
} }
function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) { function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) {
return Number(candidate.unitGrossWeightKg || 0) * Number(candidate.availablePackageCount || 0) return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0)
} }
function candidateTotalVolume(candidate: SmartLoadingCandidate) { function candidateTotalVolume(candidate: SmartLoadingCandidate) {
return candidate.availableVolumeCbm == null return candidate.availableVolumeCbm == null
? Number(candidate.averageUnitVolumeCbm || 0) * Number(candidate.availablePackageCount || 0) ? candidate.averageUnitVolumeCbm == null ? null : Number(candidate.averageUnitVolumeCbm) * Number(candidate.availablePackageCount || 0)
: Number(candidate.availableVolumeCbm) : Number(candidate.availableVolumeCbm)
} }
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => { const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
@@ -178,14 +178,13 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0)) 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 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 totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(selectedContainerResource.value?.capacityCbm || 0) * 1.1) const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.5)
const weightLimit = computed(() => Number(selectedContainerResource.value?.maxWeightKg || 0) * 1.1) const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.5)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0) const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0) const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value)) const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value)) const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const candidateOrderStatusOptions = [ const candidateOrderStatusOptions = [
{ value: '', label: '全部' },
{ value: 'PENDING_REVIEW', label: '待审核' }, { value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' }, { value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'WAIT_SPLIT', label: '待分柜' }, { value: 'WAIT_SPLIT', label: '待分柜' },
@@ -199,7 +198,7 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 210 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 210 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260 }, { key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260 },
{ key: 'orderStatus', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 160, options: candidateOrderStatusOptions }, { key: 'orderStatuses', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 210, options: candidateOrderStatusOptions, props: { multiple: true, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 2 } },
]) ])
const filteredContainers = computed(() => containers.value) const filteredContainers = computed(() => containers.value)
@@ -218,7 +217,7 @@ function preferredContainerId() {
} }
const candidateColumns: DataTableColumn[] = [ const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
@@ -231,18 +230,21 @@ const candidateColumns: DataTableColumn[] = [
{ key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' }, { key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' },
] ]
const itemColumns: DataTableColumn[] = [ const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 80, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'packageCount', label: '分配箱数', width: 100, slotName: 'packageCount' }, { key: 'packageCount', label: '分配箱数', width: 100, slotName: 'packageCount' },
{ key: 'grossWeightKg', label: '毛重', width: 80, slotName: 'weight' }, { key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积', width: 80, slotName: 'volume' }, { key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
] ]
function formatNumber(value: number, digits = 3) { function formatNumber(value: number, digits = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
} }
function formatMeasurement(value: number | null | undefined, unit: string, digits = 3) {
return value == null ? '待测量' : `${formatNumber(Number(value), digits)} ${unit}`
}
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 } function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow } function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
function itemRow(source: object) { return source as SmartLoadingInboundItem } function itemRow(source: object) { return source as SmartLoadingInboundItem }
@@ -251,17 +253,17 @@ function adjustmentMax(source: object) {
const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0 const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0
return Math.max(item.minPackageCount, item.packageCount + remaining) return Math.max(item.minPackageCount, item.packageCount + remaining)
} }
function displayCabinetNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { function displayCabinetNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return '待生成' if (!container) return '待生成'
const cabinetReference = String(container.cabinetNo || '').trim() const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成' return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
} }
function hasRealContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { function hasRealContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return false if (!container) return false
const value = String(container.containerNo || '').trim() const value = String(container.containerNo || '').trim()
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value) return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
} }
function displayActualContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { function displayActualContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录' return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录'
} }
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
@@ -270,6 +272,7 @@ function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> =
} }
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) { function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
plan.value = next plan.value = next
planGroupsByInboundId.value = loadSmartLoadingPlanCoLoadGroups(next)
noDraft.value = false noDraft.value = false
syncDrafts(next, preserved) syncDrafts(next, preserved)
const container = selectedContainer.value const container = selectedContainer.value
@@ -300,7 +303,9 @@ function candidateQueryParams(): Parameters<typeof listSmartLoadingCandidates>[0
const warehouseId = String(appliedQuery.value.warehouseId || '') const warehouseId = String(appliedQuery.value.warehouseId || '')
const customerId = String(appliedQuery.value.customerId || '') const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim() const inboundCode = String(appliedQuery.value.inboundCode || '').trim()
const orderStatus = String(appliedQuery.value.orderStatus || '') as typeof params.orderStatus const orderStatuses = Array.isArray(appliedQuery.value.orderStatuses)
? appliedQuery.value.orderStatuses.map((value) => String(value)).filter(Boolean) as NonNullable<typeof params.orderStatuses>
: []
if (warehouseId) params.warehouseId = warehouseId if (warehouseId) params.warehouseId = warehouseId
if (customerId) params.customerId = customerId if (customerId) params.customerId = customerId
if (inboundCode) params.inboundCode = inboundCode if (inboundCode) params.inboundCode = inboundCode
@@ -308,7 +313,7 @@ function candidateQueryParams(): Parameters<typeof listSmartLoadingCandidates>[0
params.createdStart = createdRange[0] params.createdStart = createdRange[0]
params.createdEnd = createdRange[1] params.createdEnd = createdRange[1]
} }
if (orderStatus) params.orderStatus = orderStatus if (orderStatuses.length) params.orderStatuses = orderStatuses
return params return params
} }
@@ -356,7 +361,6 @@ async function loadSelectedPlan(preserved: Record<string, number> = {}) {
if (request !== selectionRequest) return if (request !== selectionRequest) return
if (planResult.value) { if (planResult.value) {
acceptPlan(planResult.value, preserved, false) acceptPlan(planResult.value, preserved, false)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value)
syncDrafts(planResult.value, preserved) syncDrafts(planResult.value, preserved)
} }
else noDraft.value = planResult.missing else noDraft.value = planResult.missing
@@ -397,17 +401,11 @@ async function loadAll(preserved: Record<string, number> = {}) {
loading.value = true loading.value = true
loadError.value = null loadError.value = null
try { try {
const firstPage = await listContainers({ page: 1, size: 100 }) warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containerCatalog.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
const availableChannelCodes = new Set(channelOptions.value.map((item) => item.code)) const availableChannelCodes = new Set(channelOptions.value.map((item) => item.code))
selectedChannelCodes.value = selectedChannelCodes.value.filter((code) => availableChannelCodes.has(code)) selectedChannelCodes.value = selectedChannelCodes.value.filter((code) => availableChannelCodes.has(code))
if (!selectedChannelCodes.value.length) { if (!selectedChannelCodes.value.length) {
selectedChannelCodes.value = [channelOptions.value.find((channel) => orchestratableContainers.value.some((item) => ( selectedChannelCodes.value = [channelOptions.value[0]?.code || '']
resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === channel.code
)))?.code || channelOptions.value[0]?.code || '']
} }
await loadWorkbenchData(preserved) await loadWorkbenchData(preserved)
} catch (error) { } catch (error) {
@@ -443,7 +441,10 @@ async function selectChannels(codes: string[]) {
await loadWorkbenchData() await loadWorkbenchData()
} }
async function applyFilters(model: QueryModel) { async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model } appliedQuery.value = {
...model,
orderStatuses: Array.isArray(model.orderStatuses) ? model.orderStatuses.map((value) => String(value)) : [],
}
candidatePage.value = 1 candidatePage.value = 1
await loadCandidateData() await loadCandidateData()
} }
@@ -479,7 +480,7 @@ async function assign() {
const selected = selectedCandidate.value! const selected = selectedCandidate.value!
acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW' acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW'
? await assignPendingReviewSmartLoadingOrder(plan.value!.id, { ? await assignPendingReviewSmartLoadingOrder(plan.value!.id, {
customerId: selected.customerId, customerId: selected.accountId,
orderId: selected.orderId, orderId: selected.orderId,
inboundCode: selected.inboundCode || '', inboundCode: selected.inboundCode || '',
packageCount: assignForm.packageCount, packageCount: assignForm.packageCount,
@@ -493,14 +494,14 @@ async function assign() {
expectedVersion: plan.value!.version, expectedVersion: plan.value!.version,
})) }))
assignOpen.value = false assignOpen.value = false
await loadWorkbenchData() await loadCandidateData()
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次') ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次')
}, '分配明细失败', { preserveAssign: true }) }, '分配明细失败', { preserveAssign: true })
} }
function candidateBlocked(candidate: SmartLoadingCandidateRow) { function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') { if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') {
return Boolean(candidate.blockReason) || candidate.locked === true return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundCode || candidate.availablePackageCount < 1 || !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1
} }
return Boolean(candidate.blockReason) || candidate.locked === true return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundId || !candidate.inboundId
@@ -520,7 +521,7 @@ async function recommend() {
if (!plan.value) throw error if (!plan.value) throw error
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)) acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
} }
await loadWorkbenchData() await loadCandidateData()
ElMessage.success('智能推荐已生成') ElMessage.success('智能推荐已生成')
}, '智能推荐失败') }, '智能推荐失败')
} }
@@ -536,7 +537,7 @@ async function adjust(source: object) {
if (count > max) { ElMessage.warning(`该明细最多可分配 ${max} 箱`); return } if (count > max) { ElMessage.warning(`该明细最多可分配 ${max} 箱`); return }
await mutate(async () => { await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version)) acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
await loadWorkbenchData() await loadCandidateData()
ElMessage.success('箱数已调整') ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } }) }, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
} }
@@ -553,7 +554,7 @@ async function remove(source: object) {
await mutate(async () => { await mutate(async () => {
if (row.coLoadGroup) { if (row.coLoadGroup) {
acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version)) acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await loadWorkbenchData() await loadCandidateData()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`) ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return return
} }
@@ -564,7 +565,7 @@ async function remove(source: object) {
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version) current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
acceptPlan(current) acceptPlan(current)
} }
await loadWorkbenchData() await loadCandidateData()
ElMessage.success('该入仓码已从当前柜次移除') ElMessage.success('该入仓码已从当前柜次移除')
}, '移除装柜明细失败', { pickingConflict: true }) }, '移除装柜明细失败', { pickingConflict: true })
} }
@@ -577,21 +578,8 @@ async function removeAll() {
}).catch(() => false) }).catch(() => false)
if (!confirmed || !plan.value) return if (!confirmed || !plan.value) return
await mutate(async () => { await mutate(async () => {
let current = plan.value! acceptPlan(await removeAllUnpickedSmartLoadingItems(plan.value!.id, plan.value!.version))
for (const inboundItem of removableItems) { await loadCandidateData()
if (inboundItem.coLoadGroup) {
current = await removeSmartLoadingGroup(current.id, inboundItem.coLoadGroup.groupId, current.version)
acceptPlan(current)
continue
}
for (const original of inboundItem.sourceItems) {
const item = current.items.find((value) => value.id === original.id)
if (!item) continue
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
acceptPlan(current)
}
}
await loadWorkbenchData()
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`) ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
}, '全部移除失败', { pickingConflict: true }) }, '全部移除失败', { pickingConflict: true })
} }
@@ -600,6 +588,7 @@ async function mutate(
fallback: string, fallback: string,
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {}, recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
) { ) {
if (mutating.value) return
mutating.value = true mutating.value = true
mutationError.value = null mutationError.value = null
try { try {
@@ -690,19 +679,19 @@ onMounted(() => { void load(); void loadCustomers() })
<el-button <el-button
v-if="canAssign && assignable" v-if="canAssign && assignable"
size="small" size="small"
type="primary" type="text"
plain plain
:disabled="candidateBlocked(row as SmartLoadingCandidateRow)" :disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)" @click="openAssign(row)"
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button> ><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
</template> </template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template> <template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template> <template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template> <template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0), 1) }} kg</template> <template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatNumber(Number(row.totalGrossWeightKg || 0), 1) }} kg</template> <template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template> <template #averageUnitVolume="{ row }">{{ formatMeasurement(row.averageUnitVolumeCbm, 'm³') }}</template>
<template #candidateTotalVolume="{ row }">{{ formatNumber(Number(row.totalVolumeCbm || 0)) }} m³</template> <template #candidateTotalVolume="{ row }">{{ formatMeasurement(row.totalVolumeCbm, 'm³') }}</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template> <template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable> </DataTable>
</section> </section>
@@ -768,10 +757,10 @@ onMounted(() => { void load(); void loadCustomers() })
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div> <div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div> </div>
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细"> <DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template> <template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template> <template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>固定 {{ row.coLoadGroup.fullPackageCount }} 箱 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span></div><span v-else>普通单票</span></template> <template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else content="普通单票" /></template>
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="mutating || !canAssign || !editable || !row.canAdjust" controls-position="right" @change="adjust(row)" /><small v-if="row.detailCount > 1 && editable">多规格,需删除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template> <template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="mutating || !canAssign || !editable || !row.canAdjust" controls-position="right" @change="adjust(row)" /><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template> <template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template> <template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable> </DataTable>
@@ -782,13 +771,17 @@ onMounted(() => { void load(); void loadCustomers() })
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign"> <AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog"> <div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p> <p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ selectedContainer?.channelName || '—' }}</p>
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工排柜,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
<template v-if="selectedCandidate.coLoadGroupId"> <template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p> <p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p> <p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /> <el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template> </template>
<template v-else> <template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p> <p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small> <small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form> <el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
</template> </template>
@@ -854,7 +847,9 @@ onMounted(() => { void load(); void loadCustomers() })
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; } .plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; } .assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; } .assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; gap: 4px; } .candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; }
.candidate-block { color: var(--el-color-danger) !important; } .candidate-block { color: var(--el-color-danger) !important; }
@media (max-width: 1180px) { @media (max-width: 1180px) {
:deep(.smart-loading-query .query-toolbar__filters),:deep(.smart-loading-query .query-toolbar__tail),:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: wrap; overflow: visible; } :deep(.smart-loading-query .query-toolbar__filters),:deep(.smart-loading-query .query-toolbar__tail),:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: wrap; overflow: visible; }
@@ -29,7 +29,7 @@ const container = {
const planResponse = { const planResponse = {
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2, id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, items: [], pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], items: [],
} }
const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null } 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[]) { function candidatePage(records: unknown[]) {
@@ -43,6 +43,15 @@ function candidatePage(records: unknown[]) {
} }
describe('SmartLoadingWorkbenchView', () => { describe('SmartLoadingWorkbenchView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;')
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
})
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.clearAllMocks() vi.clearAllMocks()
@@ -63,7 +72,7 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).not.toContain('LP-99') expect(wrapper.text()).not.toContain('LP-99')
expect(wrapper.text()).toContain('IN-1') expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).not.toContain('ORD-1') expect(wrapper.text()).not.toContain('ORD-1')
expect(wrapper.text()).toContain('110% 容量上限') expect(wrapper.text()).toContain('150% 容量上限')
}) })
it('uses loading plan capabilities to keep PDA increments editable', async () => { it('uses loading plan capabilities to keep PDA increments editable', async () => {
@@ -179,7 +188,7 @@ describe('SmartLoadingWorkbenchView', () => {
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises() await flushPromises()
await wrapper.findAll('button').find((button) => button.text() === '分配')!.trigger('click') await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises() await flushPromises()
expect(document.body.textContent).toContain('本次计划装柜箱数') expect(document.body.textContent).toContain('本次计划装柜箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click() ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
@@ -118,8 +118,8 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
}) })
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 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 totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1) const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.5)
const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.1) const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.5)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0) const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0) const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value)) const displayedVolumePercent = computed(() => percentage(volumePercent.value))
@@ -158,7 +158,7 @@ function explainCandidate(source: object) {
const weight = Number(candidate.unitGrossWeightKg || 0) const weight = Number(candidate.unitGrossWeightKg || 0)
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱` return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
} }
function displayCabinetNo(value: ContainerBookingResource | null | undefined) { function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '待生成' if (!value) return '待生成'
@@ -351,7 +351,7 @@ onMounted(load)
</section> </section>
<template v-else> <template v-else>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section> <section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section> <section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 150% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 150% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<el-empty v-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" /> <el-empty v-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
<template v-else-if="plan"> <template v-else-if="plan">
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" /> <el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
@@ -361,16 +361,16 @@ onMounted(load)
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template> <template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template> <template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template> <template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>全部入仓片段联动,固定 {{ row.coLoadGroup.fullPackageCount }} 箱</span></div><span v-else>普通单票</span></template> <template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else content="普通单票" /></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template> <template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="mutating || !canAssign || !editable || !row.canAdjust" controls-position="right" @change="adjust(row)" /><small v-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格,需删除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template> <template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="mutating || !canAssign || !editable || !row.canAdjust" controls-position="right" @change="adjust(row)" /><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template> <template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable> </DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物"> <DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template> <template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template> <template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template> <template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template> <template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template> <template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template> <template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable> </DataTable>
@@ -407,8 +407,9 @@ onMounted(load)
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; } .workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; } .assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; } .assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; width: fit-content; gap: 3px; } .candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
.candidate-group > span,.candidate-group > small { color: var(--cc-muted); font-size: 11px; } .candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: 11px; }
.candidate-group .candidate-block { color: var(--el-color-danger); } .candidate-group .candidate-block { color: var(--el-color-danger); }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } } @media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
</style> </style>
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it } from 'vitest'
import { loadSmartLoadingPlanCoLoadGroups } from './smartLoadingPlanGroups' import { loadSmartLoadingPlanCoLoadGroups } from './smartLoadingPlanGroups'
import type { SmartLoadingPlan } from './types' import type { SmartLoadingPlan } from './types'
@@ -13,24 +13,22 @@ const plan = {
id: '99', planNo: 'LP-99', status: 'EDITING', bookingId: '88', containerNo: null, voyageNo: null, version: 2, id: '99', planNo: 'LP-99', status: 'EDITING', bookingId: '88', containerNo: null, voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', pdaTaskId: null, canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', pdaTaskId: null,
pdaTaskStatus: null, pdaTaskVersion: null, pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [{ groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2,
inboundFragmentCount: 2, fullPackageCount: 12, memberInboundIds: ['201', '202'] }],
items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }], items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }],
} satisfies SmartLoadingPlan } satisfies SmartLoadingPlan
describe('smart loading plan co-load groups', () => { describe('smart loading plan co-load groups', () => {
it('rebuilds the locked group from the current inbound association contract', async () => { it('uses the group snapshot already returned with the plan without per-inbound requests', () => {
const lookup = vi.fn(async (inboundId: string) => ({ const result = loadSmartLoadingPlanCoLoadGroups(plan)
inboundId, inboundCode: `IN-${inboundId}`, loadingPlanId: '99', coLoadGroupId: '9001', coLoadGroupNo: 'CLG-9001', items: [],
}))
const result = await loadSmartLoadingPlanCoLoadGroups(plan, lookup)
expect(lookup).toHaveBeenCalledTimes(2)
expect(result.get('201')).toEqual({ expect(result.get('201')).toEqual({
groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2, inboundFragmentCount: 2, fullPackageCount: 12, groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2, inboundFragmentCount: 2, fullPackageCount: 12,
}) })
expect(result.get('202')).toBe(result.get('201')) expect(result.get('202')).toBe(result.get('201'))
}) })
it('leaves ordinary plan items ungrouped', async () => { it('leaves ordinary plan items ungrouped', () => {
const result = await loadSmartLoadingPlanCoLoadGroups(plan, async (inboundId) => ({ inboundId, inboundCode: `IN-${inboundId}`, items: [] })) const result = loadSmartLoadingPlanCoLoadGroups({ ...plan, coLoadGroups: [] })
expect(result.size).toBe(0) expect(result.size).toBe(0)
}) })
}) })
@@ -1,5 +1,3 @@
import { getLoadingAssociationSummaryByInboundId, type LoadingAssociationSummary } from '@/shared/api/inboundCodeRelations'
import { groupSmartLoadingItems } from './smartLoadingItems'
import type { SmartLoadingPlan } from './types' import type { SmartLoadingPlan } from './types'
export interface SmartLoadingPlanCoLoadGroup { export interface SmartLoadingPlanCoLoadGroup {
@@ -11,37 +9,18 @@ export interface SmartLoadingPlanCoLoadGroup {
fullPackageCount: number fullPackageCount: number
} }
type AssociationLookup = (inboundId: string) => Promise<LoadingAssociationSummary> export function loadSmartLoadingPlanCoLoadGroups(plan: SmartLoadingPlan) {
export async function loadSmartLoadingPlanCoLoadGroups(
plan: SmartLoadingPlan,
lookup: AssociationLookup = getLoadingAssociationSummaryByInboundId,
) {
const inboundRows = groupSmartLoadingItems(plan.items || [])
if (!inboundRows.length) return new Map<string, SmartLoadingPlanCoLoadGroup>()
const associations = await Promise.all(inboundRows.map(async (row) => ({
row,
association: await lookup(row.inboundId),
})))
const membersByGroupId = new Map<string, typeof associations>()
for (const entry of associations) {
const groupId = String(entry.association.coLoadGroupId || '')
if (!groupId) continue
membersByGroupId.set(groupId, [...(membersByGroupId.get(groupId) || []), entry])
}
const groupsByInboundId = new Map<string, SmartLoadingPlanCoLoadGroup>() const groupsByInboundId = new Map<string, SmartLoadingPlanCoLoadGroup>()
for (const [groupId, members] of membersByGroupId) { for (const source of plan.coLoadGroups || []) {
const group = { const group = {
groupId, groupId: source.groupId,
groupNo: members.find((entry) => entry.association.coLoadGroupNo)?.association.coLoadGroupNo || '同柜不可拆组', groupNo: source.groupNo || '同柜不可拆组',
lockedPlanId: members.find((entry) => entry.association.loadingPlanId)?.association.loadingPlanId || plan.id, lockedPlanId: source.lockedPlanId || plan.id,
memberOrderCount: new Set(members.map((entry) => entry.row.orderId)).size, memberOrderCount: source.memberOrderCount,
inboundFragmentCount: members.length, inboundFragmentCount: source.inboundFragmentCount,
fullPackageCount: members.reduce((sum, entry) => sum + entry.row.packageCount, 0), fullPackageCount: source.fullPackageCount,
} satisfies SmartLoadingPlanCoLoadGroup } satisfies SmartLoadingPlanCoLoadGroup
for (const { row } of members) groupsByInboundId.set(row.inboundId, group) for (const inboundId of source.memberInboundIds) groupsByInboundId.set(inboundId, group)
} }
return groupsByInboundId return groupsByInboundId
} }
+12 -1
View File
@@ -279,7 +279,7 @@ export interface SmartLoadingCandidateQuery {
inboundCode?: string inboundCode?: string
createdStart?: string createdStart?: string
createdEnd?: string createdEnd?: string
orderStatus?: 'PENDING_REVIEW' | 'WAIT_MEASURE' | 'WAIT_SPLIT' | 'LOADING' orderStatuses?: Array<'PENDING_REVIEW' | 'WAIT_MEASURE' | 'WAIT_SPLIT' | 'LOADING'>
page: number page: number
size: number size: number
} }
@@ -302,6 +302,8 @@ export interface SmartLoadingWorkbenchContainer {
channelCode: string channelCode: string
channelName: string channelName: string
containerType: string containerType: string
capacityCbm: number | null
maxWeightKg: number | null
planId: string | null planId: string | null
planNo: string | null planNo: string | null
planStatus: Exclude<ContainerPlanStatus, 'COMPLETED'> planStatus: Exclude<ContainerPlanStatus, 'COMPLETED'>
@@ -358,5 +360,14 @@ export interface SmartLoadingPlan {
pdaTaskId: string | null pdaTaskId: string | null
pdaTaskStatus: 'LOADING' | 'COMPLETED' | null pdaTaskStatus: 'LOADING' | 'COMPLETED' | null
pdaTaskVersion: number | null pdaTaskVersion: number | null
coLoadGroups: Array<{
groupId: string
groupNo: string
lockedPlanId: string | null
memberOrderCount: number
inboundFragmentCount: number
fullPackageCount: number
memberInboundIds: string[]
}>
items: SmartLoadingItem[] items: SmartLoadingItem[]
} }
@@ -9,6 +9,8 @@ const orderListSource = readFileSync(resolve(process.cwd(), 'src/modules/unified
describe('InboundMeasurementDialog', () => { describe('InboundMeasurementDialog', () => {
it('owns the complete measurement workspace behind one centered dialog', () => { it('owns the complete measurement workspace behind one centered dialog', () => {
expect(source).toContain("import AppDialog from '@/shared/components/AppDialog.vue'")
expect(source).toContain('<AppDialog')
expect(source).toContain('class="inbound-measurement-workspace-dialog"') expect(source).toContain('class="inbound-measurement-workspace-dialog"')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"') expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
expect(source).toContain('height: 92vh') expect(source).toContain('height: 92vh')
@@ -21,6 +23,12 @@ describe('InboundMeasurementDialog', () => {
expect(source).toContain('measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)') expect(source).toContain('measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)')
}) })
it('offers the shared fullscreen control without forcing fullscreen on open', () => {
expect(source).toContain('fullscreenable')
expect(source).not.toContain('default-fullscreen')
expect(source).not.toContain('<el-dialog')
})
it('shows the inbound code once in the dialog title and not as a second body heading', () => { it('shows the inbound code once in the dialog title and not as a second body heading', () => {
expect(source).toContain("`测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`") expect(source).toContain("`测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`")
expect(source).not.toContain('props.inboundCode?.trim() || props.inboundId') expect(source).not.toContain('props.inboundCode?.trim() || props.inboundId')
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import InboundMeasurementPanel from './InboundMeasurementPanel.vue' import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue' import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
@@ -59,12 +60,13 @@ function handleSubmitted() {
</script> </script>
<template> <template>
<el-dialog <AppDialog
:model-value="modelValue" :model-value="modelValue"
class="inbound-measurement-workspace-dialog" class="inbound-measurement-workspace-dialog"
:title="title" :title="title"
width="min(1240px, calc(100vw - 32px))" width="min(1240px, calc(100vw - 32px))"
top="4vh" top="4vh"
fullscreenable
destroy-on-close destroy-on-close
append-to-body append-to-body
:close-on-click-modal="false" :close-on-click-modal="false"
@@ -111,7 +113,7 @@ function handleSubmitted() {
>提交审核</el-button> >提交审核</el-button>
</div> </div>
</template> </template>
</el-dialog> </AppDialog>
</template> </template>
<style scoped> <style scoped>
+16
View File
@@ -11,6 +11,7 @@ const ElDialogStub = defineComponent({
showClose: Boolean, showClose: Boolean,
closeOnClickModal: Boolean, closeOnClickModal: Boolean,
closeOnPressEscape: Boolean, closeOnPressEscape: Boolean,
fullscreen: Boolean,
}, },
template: '<section data-testid="dialog"><slot name="header" /><slot /><slot name="footer" /></section>', template: '<section data-testid="dialog"><slot name="header" /><slot /><slot name="footer" /></section>',
}) })
@@ -65,10 +66,25 @@ describe('AppDialog', () => {
expect(wrapper.findAll('button')).toHaveLength(0) expect(wrapper.findAll('button')).toHaveLength(0)
}) })
it('provides opt-in fullscreen controls without changing ordinary dialogs', async () => {
const ordinary = mountDialog()
expect(ordinary.getComponent(ElDialogStub).props('fullscreen')).toBe(false)
expect(ordinary.find('button[aria-label="展开全屏"]').exists()).toBe(false)
const wrapper = mountDialog({ fullscreenable: true, defaultFullscreen: true })
expect(wrapper.getComponent(ElDialogStub).props('fullscreen')).toBe(true)
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
expect(wrapper.getComponent(ElDialogStub).props('fullscreen')).toBe(false)
expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true)
})
it('keeps the dialog chrome fixed while long content scrolls in the body', () => { it('keeps the dialog chrome fixed while long content scrolls in the body', () => {
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDialog.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDialog.vue'), 'utf8')
expect(source).toContain(':global(.app-dialog.el-dialog) { display: flex;') expect(source).toContain(':global(.app-dialog.el-dialog) { display: flex;')
expect(source).toContain('width: 100%; max-width: 100%; height: 100vh;')
expect(source).toContain(':global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto;') expect(source).toContain(':global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto;')
expect(source).toContain('max-height: calc(100svh - 20px)') expect(source).toContain('max-height: calc(100svh - 20px)')
}) })
+35 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { Maximize2, Minimize2 } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info' export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
@@ -22,6 +23,8 @@ const props = withDefaults(defineProps<{
closeOnPressEscape?: boolean closeOnPressEscape?: boolean
destroyOnClose?: boolean destroyOnClose?: boolean
lockWhileSubmitting?: boolean lockWhileSubmitting?: boolean
fullscreenable?: boolean
defaultFullscreen?: boolean
}>(), { }>(), {
width: 'min(560px, calc(100vw - 28px))', width: 'min(560px, calc(100vw - 28px))',
loading: false, loading: false,
@@ -38,15 +41,26 @@ const props = withDefaults(defineProps<{
closeOnPressEscape: true, closeOnPressEscape: true,
destroyOnClose: false, destroyOnClose: false,
lockWhileSubmitting: true, lockWhileSubmitting: true,
fullscreenable: false,
defaultFullscreen: false,
}) })
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const fullscreen = ref(props.defaultFullscreen)
const emit = defineEmits<{ const emit = defineEmits<{
confirm: [] confirm: []
cancel: [] cancel: []
}>() }>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading) const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
watch(open, (value) => {
if (value) fullscreen.value = props.defaultFullscreen
})
function toggleFullscreen() {
fullscreen.value = !fullscreen.value
}
function closeDialog() { function closeDialog() {
if (submittingLocked.value) return if (submittingLocked.value) return
open.value = false open.value = false
@@ -65,8 +79,23 @@ function closeDialog() {
:close-on-click-modal="closeOnClickModal && !submittingLocked" :close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked" :close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose" :destroy-on-close="destroyOnClose"
:fullscreen="fullscreenable && fullscreen"
> >
<template v-if="$slots.header" #header><slot name="header" /></template> <template v-if="$slots.header || fullscreenable" #header>
<div v-if="fullscreenable" class="app-dialog__header">
<div class="app-dialog__header-content">
<slot v-if="$slots.header" name="header" :fullscreen="fullscreen" :toggle-fullscreen="toggleFullscreen" />
<span v-else>{{ title }}</span>
</div>
<el-tooltip :content="fullscreen ? '退出全屏' : '展开全屏'" placement="bottom">
<el-button text :aria-label="fullscreen ? '退出全屏' : '展开全屏'" @click="toggleFullscreen">
<Minimize2 v-if="fullscreen" :size="18" />
<Maximize2 v-else :size="18" />
</el-button>
</el-tooltip>
</div>
<slot v-else name="header" />
</template>
<div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading"> <div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading">
<slot /> <slot />
</div> </div>
@@ -89,8 +118,12 @@ function closeDialog() {
<style scoped> <style scoped>
:global(.app-dialog.el-dialog) { display: flex; max-height: min(90vh, 900px); flex-direction: column; overflow: hidden; } :global(.app-dialog.el-dialog) { display: flex; max-height: min(90vh, 900px); flex-direction: column; overflow: hidden; }
:global(.app-dialog.el-dialog.is-fullscreen) { width: 100%; max-width: 100%; height: 100vh; max-height: 100vh; margin: 0 !important; border-radius: 0; }
:global(.app-dialog .el-dialog__header), :global(.app-dialog .el-dialog__footer) { flex: 0 0 auto; } :global(.app-dialog .el-dialog__header), :global(.app-dialog .el-dialog__footer) { flex: 0 0 auto; }
:global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; } :global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.app-dialog__header { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; color: var(--cc-text); font-size: 18px; font-weight: 600; line-height: 24px; }
.app-dialog__header-content { min-width: 0; flex: 1; }
.app-dialog__header > :deep(.el-button) { flex: 0 0 auto; margin-right: 24px; color: var(--cc-muted); }
.app-dialog__body { min-height: 0; } .app-dialog__body { min-height: 0; }
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; } .app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-dialog__footer :deep(.el-button) { margin-left: 0; } .app-dialog__footer :deep(.el-button) { margin-left: 0; }