Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts: # src/modules/container-loading/pages/GlobalSmartLoadingView.vue # src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue # src/modules/domestic-containers/pages/DomesticContainersView.vue
This commit is contained in:
@@ -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`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` |
|
| 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `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`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` |
|
||||||
| 合柜报关 / 同柜不可拆组 | `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,7 +66,7 @@ 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', 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 })
|
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
|
const candidateParams = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
|
||||||
@@ -144,6 +144,17 @@ describe('platform container loading API', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses one endpoint to remove all unpicked plan items', async () => {
|
||||||
|
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
|
||||||
|
|
||||||
|
await removeAllUnpickedSmartLoadingItems('10', 6)
|
||||||
|
|
||||||
|
expect(http.delete).toHaveBeenCalledWith(
|
||||||
|
'/platform/v1/first-leg/smart-loading/plans/10/unpicked-items',
|
||||||
|
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-unpicked-') } },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses stable container id and versioned clearance document actions', async () => {
|
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 } })
|
||||||
|
|||||||
@@ -259,23 +259,26 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue
|
|||||||
...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),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,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,8 @@ import {
|
|||||||
listSmartLoadingCandidates,
|
listSmartLoadingCandidates,
|
||||||
listSmartLoadingWorkbenchContainers,
|
listSmartLoadingWorkbenchContainers,
|
||||||
recommendSmartLoading,
|
recommendSmartLoading,
|
||||||
|
adjustSmartLoadingItem,
|
||||||
|
removeAllUnpickedSmartLoadingItems,
|
||||||
removeSmartLoadingGroup,
|
removeSmartLoadingGroup,
|
||||||
removeSmartLoadingItem,
|
removeSmartLoadingItem,
|
||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
@@ -47,6 +49,7 @@ 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('@/modules/warehouses', () => ({
|
vi.mock('@/modules/warehouses', () => ({
|
||||||
@@ -453,7 +456,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
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('目标柜次:CB-1 · 集装箱号:MSCU-1')
|
||||||
expect(document.body.textContent).toContain('发货仓:义乌仓 · 渠道:美森正班')
|
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()
|
||||||
@@ -466,10 +469,12 @@ 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,
|
unitGrossWeightKg: null,
|
||||||
averageUnitVolumeCbm: null,
|
averageUnitVolumeCbm: null,
|
||||||
availableVolumeCbm: null,
|
availableVolumeCbm: null,
|
||||||
@@ -488,6 +493,9 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
|
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
|
||||||
expect(document.body.textContent).toContain('平均单箱体积 待测量')
|
expect(document.body.textContent).toContain('平均单箱体积 待测量')
|
||||||
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
|
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()
|
||||||
@@ -518,11 +526,32 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||||
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
||||||
expect(source).toContain('@change="adjust(row)"')
|
expect(source).toContain(':controls="false"')
|
||||||
|
expect(source).toContain('@blur="adjust(row)" @keyup.enter="adjust(row)"')
|
||||||
expect(source).not.toContain('<Save')
|
expect(source).not.toContain('<Save')
|
||||||
expect(source).toContain('.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
expect(source).toContain('.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('saves a typed package count through the adjustment API when editing finishes', async () => {
|
||||||
|
vi.mocked(adjustSmartLoadingItem).mockResolvedValue({
|
||||||
|
...plan,
|
||||||
|
version: 3,
|
||||||
|
items: [{ ...plan.items[0], packageCount: 4, version: 2 }],
|
||||||
|
})
|
||||||
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const quantityInput = wrapper.findAllComponents(ElInputNumber).find((input) => input.props('modelValue') === 6)
|
||||||
|
expect(quantityInput?.props('controls')).toBe(false)
|
||||||
|
quantityInput?.vm.$emit('update:modelValue', 4)
|
||||||
|
await flushPromises()
|
||||||
|
quantityInput?.vm.$emit('blur')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(adjustSmartLoadingItem).toHaveBeenCalledTimes(1)
|
||||||
|
expect(adjustSmartLoadingItem).toHaveBeenCalledWith(plan.id, plan.items[0].id, 4, plan.version, plan.items[0].version)
|
||||||
|
})
|
||||||
|
|
||||||
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
|
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||||
...plan,
|
...plan,
|
||||||
@@ -717,13 +746,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] } })
|
||||||
@@ -733,8 +761,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('当前柜次暂无装柜明细')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
listSmartLoadingCandidates,
|
listSmartLoadingCandidates,
|
||||||
listSmartLoadingWorkbenchContainers,
|
listSmartLoadingWorkbenchContainers,
|
||||||
recommendSmartLoading,
|
recommendSmartLoading,
|
||||||
|
removeAllUnpickedSmartLoadingItems,
|
||||||
removeSmartLoadingGroup,
|
removeSmartLoadingGroup,
|
||||||
removeSmartLoadingItem,
|
removeSmartLoadingItem,
|
||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
@@ -74,6 +75,11 @@ 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 crossChannelAssignment = computed(() => Boolean(
|
||||||
|
selectedCandidate.value?.channelCode
|
||||||
|
&& selectedContainer.value?.channelCode
|
||||||
|
&& selectedCandidate.value.channelCode !== selectedContainer.value.channelCode,
|
||||||
|
))
|
||||||
const orchestratableContainers = computed(() => containers.value)
|
const orchestratableContainers = computed(() => containers.value)
|
||||||
const completedContainerCount = computed(() => 0)
|
const completedContainerCount = computed(() => 0)
|
||||||
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
|
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
|
||||||
@@ -211,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' },
|
||||||
@@ -224,13 +230,13 @@ 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) {
|
||||||
@@ -474,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,
|
||||||
@@ -495,7 +501,7 @@ async function assign() {
|
|||||||
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
|
||||||
@@ -521,8 +527,9 @@ async function recommend() {
|
|||||||
}
|
}
|
||||||
async function adjust(source: object) {
|
async function adjust(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
if (!plan.value) return
|
if (!plan.value || mutating.value) return
|
||||||
const count = Number(quantityDrafts[item.id])
|
const count = Number(quantityDrafts[item.id])
|
||||||
|
if (count === item.packageCount) return
|
||||||
if (!Number.isInteger(count) || count < item.minPackageCount) {
|
if (!Number.isInteger(count) || count < item.minPackageCount) {
|
||||||
ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`)
|
ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`)
|
||||||
return
|
return
|
||||||
@@ -572,20 +579,7 @@ 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) {
|
|
||||||
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 loadCandidateData()
|
await loadCandidateData()
|
||||||
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
|
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
|
||||||
}, '全部移除失败', { pickingConflict: true })
|
}, '全部移除失败', { pickingConflict: true })
|
||||||
@@ -595,6 +589,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 {
|
||||||
@@ -685,9 +680,9 @@ 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>
|
||||||
@@ -763,10 +758,10 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
<div><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"><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 #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else content="普通单票" /></template>
|
||||||
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="mutating || !canAssign || !editable || !row.canAdjust" controls-position="right" @change="adjust(row)" placeholder="请输入调整箱数" /><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating || !canAssign || !editable || !row.canAdjust" placeholder="请输入调整箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
<template #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>
|
||||||
@@ -778,7 +773,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<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>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
|
||||||
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 渠道:{{ resolveSmartLoadingChannelGroup(selectedContainer?.channelCode, selectedContainer?.channelName)?.name || '—' }}</p>
|
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ selectedContainer?.channelName || '—' }}</p>
|
||||||
|
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
|
||||||
|
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工排柜,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||||
<template v-if="selectedCandidate.coLoadGroupId">
|
<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>
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
|
||||||
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
||||||
expect(source).toContain('@change="adjust(row)"')
|
expect(source).toContain(':controls="false"')
|
||||||
|
expect(source).toContain('@blur="adjust(row)" @keyup.enter="adjust(row)"')
|
||||||
expect(source).not.toContain('<Save')
|
expect(source).not.toContain('<Save')
|
||||||
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -262,8 +262,9 @@ async function recommend() {
|
|||||||
}
|
}
|
||||||
async function adjust(source: object) {
|
async function adjust(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
if (!plan.value) return
|
if (!plan.value || mutating.value) return
|
||||||
const count = Number(quantityDrafts[item.id])
|
const count = Number(quantityDrafts[item.id])
|
||||||
|
if (count === item.packageCount) return
|
||||||
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
|
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
@@ -363,7 +364,7 @@ onMounted(load)
|
|||||||
<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"><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 #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 }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="mutating || !canAssign || !editable || !row.canAdjust" controls-position="right" @change="adjust(row)" placeholder="请输入调整箱数" /><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :controls="false" :disabled="mutating || !canAssign || !editable || !row.canAdjust" placeholder="请输入调整箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
<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="当前仓库暂无可排柜货物">
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.vue'), 'utf8')
|
||||||
|
|
||||||
|
describe('DomesticContainerInventoryDetailView', () => {
|
||||||
|
it('does not invent an action column for immutable transaction history', () => {
|
||||||
|
expect(source).not.toContain("label: '操作'")
|
||||||
|
expect(source).toContain('进销存交易记录')
|
||||||
|
})
|
||||||
|
it('surfaces loading and API diagnostic errors and supports retry', () => {
|
||||||
|
expect(source).toContain('整柜库存详情加载失败')
|
||||||
|
expect(source).toContain('@retry="load"')
|
||||||
|
expect(source).toContain('<ApiErrorMeta :details="error" />')
|
||||||
|
expect(source).toContain('v-loading="loading"')
|
||||||
|
})
|
||||||
|
it('uses a four-column desktop field grid and preserves long business text', () => {
|
||||||
|
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
|
||||||
|
expect(source).toContain('overflow-wrap: anywhere')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,4 +12,7 @@ describe('DomesticContainersView', () => {
|
|||||||
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
|
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
|
||||||
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
|
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
|
||||||
it('matches the prototype four-column inventory summary', () => { expect(source).toContain("import './domesticContainersSummary.css'"); expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))'); expect(summaryStyles).toContain('border: 1px solid #e7edf5'); expect(summaryStyles).toContain('font-size: 20px'); expect(summaryStyles).toContain('.inventory-summary + .data-table > .data-table__toolbar') })
|
it('matches the prototype four-column inventory summary', () => { expect(source).toContain("import './domesticContainersSummary.css'"); expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))'); expect(summaryStyles).toContain('border: 1px solid #e7edf5'); expect(summaryStyles).toContain('font-size: 20px'); expect(summaryStyles).toContain('.inventory-summary + .data-table > .data-table__toolbar') })
|
||||||
|
it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") })
|
||||||
|
it('matches backend numeric and container-number input boundaries before submit', () => { expect(source).toContain("pattern: /^[A-Za-z0-9-]{4,32}$/"); expect(source).toContain(':max="999999999"'); expect(source).toContain(':max="999999999999.9999"') })
|
||||||
|
it('requires a refreshed inventory version for completed outbound submission', () => { expect(source).toContain("outboundForm.inventoryVersion == null"); expect(source).toContain('库存版本未就绪,请重新选择集装箱') })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const columns = computed<DataTableColumn[]>(() => activeTab.value === 'inventory
|
|||||||
])
|
])
|
||||||
function displayTime(value: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
function displayTime(value: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||||
function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') }
|
function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') }
|
||||||
|
function canViewInventory(row: ContainerOrder | InventoryRecord) { return 'inventoryStatus' in row || activeTab.value === 'outbound' || row.status === 'COMPLETED' }
|
||||||
function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } }
|
function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } }
|
||||||
async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } }
|
async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } }
|
||||||
async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } }
|
async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } }
|
||||||
@@ -59,20 +60,20 @@ const inboundForm = reactive({ warehouseId: '', cargoName: '', containerNo: '',
|
|||||||
const outboundForm = reactive({ warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined as number | undefined, boxCount: 1, operationTime: '', status: 'COMPLETED' as ContainerOrderStatus, remark: '' })
|
const outboundForm = reactive({ warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined as number | undefined, boxCount: 1, operationTime: '', status: 'COMPLETED' as ContainerOrderStatus, remark: '' })
|
||||||
const inventoryCandidates = ref<InventoryRecord[]>([]); const candidateLoading = ref(false); const candidateError = ref<ApiErrorDetails | null>(null)
|
const inventoryCandidates = ref<InventoryRecord[]>([]); const candidateLoading = ref(false); const candidateError = ref<ApiErrorDetails | null>(null)
|
||||||
const currentForm = computed(() => dialogType.value === 'inbound' ? inboundForm : outboundForm)
|
const currentForm = computed(() => dialogType.value === 'inbound' ? inboundForm : outboundForm)
|
||||||
const rules: FormRules = { warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }], cargoName: [{ required: true, message: '请输入货物名称', trigger: 'blur' }], containerNo: [{ required: true, message: '请选择或输入集装箱号', trigger: 'change' }], boxCount: [{ required: true, type: 'integer', min: 1, message: '箱数必须为正整数', trigger: 'change' }], volumeCbm: [{ required: true, type: 'number', min: 0.0001, message: '体积必须大于等于 0.0001', trigger: 'change' }], operationTime: [{ required: true, message: '请选择操作时间', trigger: 'change' }], status: [{ required: true, message: '请选择状态', trigger: 'change' }] }
|
const rules: FormRules = { warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }], cargoName: [{ required: true, message: '请输入货物名称', trigger: 'blur' }], containerNo: [{ required: true, message: '请选择或输入集装箱号', trigger: 'change' }, { pattern: /^[A-Za-z0-9-]{4,32}$/, message: '集装箱号须为 4–32 位字母、数字或连字符', trigger: 'blur' }], boxCount: [{ required: true, type: 'integer', min: 1, max: 999999999, message: '箱数须为 1–999999999 的整数', trigger: 'change' }], volumeCbm: [{ required: true, type: 'number', min: 0.0001, max: 999999999999.9999, message: '体积须在 0.0001–999999999999.9999 m³ 之间', trigger: 'change' }], operationTime: [{ required: true, message: '请选择操作时间', trigger: 'change' }], status: [{ required: true, message: '请选择状态', trigger: 'change' }] }
|
||||||
function localDateTime() { const offset = new Date().getTimezoneOffset() * 60_000; return `${new Date(Date.now() - offset).toISOString().slice(0, 16)}:00` }
|
function localDateTime() { const offset = new Date().getTimezoneOffset() * 60_000; return `${new Date(Date.now() - offset).toISOString().slice(0, 16)}:00` }
|
||||||
function newIdempotencyKey(type: string) { return `${type}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}` }
|
function newIdempotencyKey(type: string) { return `${type}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}` }
|
||||||
function openInbound() { dialogType.value = 'inbound'; Object.assign(inboundForm, { warehouseId: '', cargoName: '', containerNo: '', boxCount: 1, volumeCbm: 0.01, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); idempotencyKey.value = newIdempotencyKey('inbound'); submitError.value = null; dialogOpen.value = true }
|
function openInbound() { dialogType.value = 'inbound'; Object.assign(inboundForm, { warehouseId: '', cargoName: '', containerNo: '', boxCount: 1, volumeCbm: 0.01, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); idempotencyKey.value = newIdempotencyKey('inbound'); submitError.value = null; dialogOpen.value = true }
|
||||||
function openOutbound() { dialogType.value = 'outbound'; Object.assign(outboundForm, { warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined, boxCount: 1, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); inventoryCandidates.value = []; candidateError.value = null; idempotencyKey.value = newIdempotencyKey('outbound'); submitError.value = null; dialogOpen.value = true }
|
function openOutbound() { dialogType.value = 'outbound'; Object.assign(outboundForm, { warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined, boxCount: 1, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); inventoryCandidates.value = []; candidateError.value = null; idempotencyKey.value = newIdempotencyKey('outbound'); submitError.value = null; dialogOpen.value = true }
|
||||||
async function loadCandidates() { inventoryCandidates.value = []; Object.assign(outboundForm, { inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined }); if (!outboundForm.warehouseId) return; candidateLoading.value = true; candidateError.value = null; try { const result = await listDomesticContainerInventories({ page: 1, size: 200, warehouseId: outboundForm.warehouseId }); inventoryCandidates.value = result.records.filter((item) => item.availableBoxes > 0) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '可出库整柜库存加载失败') } finally { candidateLoading.value = false } }
|
async function loadCandidates() { inventoryCandidates.value = []; Object.assign(outboundForm, { inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined }); if (!outboundForm.warehouseId) return; candidateLoading.value = true; candidateError.value = null; try { const result = await listDomesticContainerInventories({ page: 1, size: 200, warehouseId: outboundForm.warehouseId }); inventoryCandidates.value = result.records.filter((item) => item.availableBoxes > 0) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '可出库整柜库存加载失败') } finally { candidateLoading.value = false } }
|
||||||
async function selectInventory(id: string) { const selected = inventoryCandidates.value.find((item) => String(item.id) === id); if (!selected) return; Object.assign(outboundForm, { inventoryId: id, containerNo: selected.containerNo, cargoName: selected.cargoName, availableBoxes: selected.availableBoxes, inventoryVersion: selected.version }); try { const detail = await getDomesticContainerInventory(id); Object.assign(outboundForm, { availableBoxes: detail.inventory.availableBoxes, inventoryVersion: detail.inventory.version }) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '库存最新版本加载失败') } }
|
async function selectInventory(id: string) { const selected = inventoryCandidates.value.find((item) => String(item.id) === id); if (!selected) return; Object.assign(outboundForm, { inventoryId: id, containerNo: selected.containerNo, cargoName: selected.cargoName, availableBoxes: selected.availableBoxes, inventoryVersion: selected.version }); try { const detail = await getDomesticContainerInventory(id); Object.assign(outboundForm, { availableBoxes: detail.inventory.availableBoxes, inventoryVersion: detail.inventory.version }) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '库存最新版本加载失败') } }
|
||||||
async function submit() { if (!await formRef.value?.validate().catch(() => false)) return; if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.boxCount > outboundForm.availableBoxes) { submitError.value = { message: `库存不足,当前可用 ${outboundForm.availableBoxes} 箱`, code: 'A0409' }; return } saving.value = true; submitError.value = null; try { const result = dialogType.value === 'inbound' ? await createDomesticContainerInbound({ warehouseId: inboundForm.warehouseId, cargoName: inboundForm.cargoName.trim(), containerNo: inboundForm.containerNo.trim().toUpperCase(), boxCount: inboundForm.boxCount, volumeCbm: inboundForm.volumeCbm, inboundTime: inboundForm.operationTime, status: inboundForm.status, remark: inboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value }) : await createDomesticContainerOutbound({ warehouseId: outboundForm.warehouseId, containerNo: outboundForm.containerNo, boxCount: outboundForm.boxCount, outboundTime: outboundForm.operationTime, status: outboundForm.status, remark: outboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value, inventoryVersion: outboundForm.status === 'COMPLETED' ? outboundForm.inventoryVersion : undefined }); ElMessage.success(result.replayed ? `请求已处理,单号 ${result.orderNo}` : `创建成功,单号 ${result.orderNo}`); dialogOpen.value = false; await load() } catch (cause) { submitError.value = getApiErrorDetails(cause, '整柜单据创建失败'); if (dialogType.value === 'outbound' && submitError.value.code === 'A0409') await loadCandidates() } finally { saving.value = false } }
|
async function submit() { if (!await formRef.value?.validate().catch(() => false)) return; if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.inventoryVersion == null) { submitError.value = { message: '库存版本未就绪,请重新选择集装箱', code: 'A0422' }; return } if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.boxCount > outboundForm.availableBoxes) { submitError.value = { message: `库存不足,当前可用 ${outboundForm.availableBoxes} 箱`, code: 'A0409' }; return } saving.value = true; submitError.value = null; try { const result = dialogType.value === 'inbound' ? await createDomesticContainerInbound({ warehouseId: inboundForm.warehouseId, cargoName: inboundForm.cargoName.trim(), containerNo: inboundForm.containerNo.trim().toUpperCase(), boxCount: inboundForm.boxCount, volumeCbm: inboundForm.volumeCbm, inboundTime: inboundForm.operationTime, status: inboundForm.status, remark: inboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value }) : await createDomesticContainerOutbound({ warehouseId: outboundForm.warehouseId, containerNo: outboundForm.containerNo, boxCount: outboundForm.boxCount, outboundTime: outboundForm.operationTime, status: outboundForm.status, remark: outboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value, inventoryVersion: outboundForm.status === 'COMPLETED' ? outboundForm.inventoryVersion : undefined }); ElMessage.success(result.replayed ? `请求已处理,单号 ${result.orderNo}` : `创建成功,单号 ${result.orderNo}`); dialogOpen.value = false; await load() } catch (cause) { submitError.value = getApiErrorDetails(cause, '整柜单据创建失败'); if (dialogType.value === 'outbound' && submitError.value.code === 'A0409') await loadCandidates() } finally { saving.value = false } }
|
||||||
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
|
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template><AdminPage as="div" class="domestic-containers-page">
|
<template><AdminPage as="div" class="domestic-containers-page">
|
||||||
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><template #toolbar><div class="business-query-panel"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template><template v-if="loadError" #state><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><section v-if="!loadError" id="domestic-container-results" class="surface-panel table-panel"><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"><template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><el-button size="small" type="primary" plain @click="viewInventory(row as ContainerOrder | InventoryRecord)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><template #toolbar><div class="business-query-panel"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template><template v-if="loadError" #state><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><section v-if="!loadError" id="domestic-container-results" class="surface-panel table-panel"><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"><template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" size="small" type="primary" plain @click="viewInventory(row as ContainerOrder | InventoryRecord)"><Eye :size="15" />查看</el-button><span v-else>—</span></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||||
|
|
||||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid #e5e7eb;background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:14px;cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 16px 12px;border:1px solid var(--cc-border);background:#f8fafc}.inventory-summary>div{display:flex;align-items:baseline;gap:8px;min-width:0;padding:9px 16px;border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:12px}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid #e5e7eb;background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:14px;cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 16px 12px;border:1px solid var(--cc-border);background:#f8fafc}.inventory-summary>div{display:flex;align-items:baseline;gap:8px;min-width:0;padding:9px 16px;border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:12px}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||||
|
|||||||
@@ -117,6 +117,27 @@ describe('InboundFeePanel', () => {
|
|||||||
expect(wrapper.find('.surface-panel').exists()).toBe(false)
|
expect(wrapper.find('.surface-panel').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports a display-only fee snapshot without calculation or transition actions', async () => {
|
||||||
|
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
|
||||||
|
const pinia = setupPermissions([
|
||||||
|
'firstleg:fba-pricing:view', 'firstleg:fba-pricing:calculate', 'firstleg:fba-pricing:adjust',
|
||||||
|
'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee',
|
||||||
|
])
|
||||||
|
const wrapper = mount(InboundFeePanel, {
|
||||||
|
props: { customerId: '10001', inboundId: '88', displayOnly: true },
|
||||||
|
global: { plugins: [pinia, ElementPlus] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('button[aria-label="刷新费用快照"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).not.toContain('费用试算')
|
||||||
|
expect(wrapper.text()).not.toContain('确认费用')
|
||||||
|
expect(wrapper.text()).not.toContain('确认推送费用')
|
||||||
|
expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-testid="confirm-fee-button"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => {
|
it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => {
|
||||||
const pinia = setupPermissions(['firstleg:fba-pricing:view'])
|
const pinia = setupPermissions(['firstleg:fba-pricing:view'])
|
||||||
mount(InboundFeePanel, {
|
mount(InboundFeePanel, {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const props = defineProps<{
|
|||||||
tenantId?: string
|
tenantId?: string
|
||||||
inboundId: string
|
inboundId: string
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
displayOnly?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -352,20 +353,20 @@ defineExpose({ reload: loadFee })
|
|||||||
><RefreshCw :size="16" /></el-button>
|
><RefreshCw :size="16" /></el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canCalculate"
|
v-if="!displayOnly && canCalculate"
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
:loading="trialLoading"
|
:loading="trialLoading"
|
||||||
@click="openTrial"
|
@click="openTrial"
|
||||||
><CircleDollarSign :size="16" />费用试算</el-button>
|
><CircleDollarSign :size="16" />费用试算</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canAdjust && adjustmentEligible"
|
v-if="!displayOnly && canAdjust && adjustmentEligible"
|
||||||
data-testid="adjust-fee-button"
|
data-testid="adjust-fee-button"
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="openAdjustment"
|
@click="openAdjustment"
|
||||||
><PencilLine :size="16" />调整初算费用</el-button>
|
><PencilLine :size="16" />调整初算费用</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canConfirm && snapshot?.feeStatus === 'CALCULATED'"
|
v-if="!displayOnly && canConfirm && snapshot?.feeStatus === 'CALCULATED'"
|
||||||
data-testid="confirm-fee-button"
|
data-testid="confirm-fee-button"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="feeAction === 'confirm'"
|
:loading="feeAction === 'confirm'"
|
||||||
@@ -373,14 +374,14 @@ defineExpose({ reload: loadFee })
|
|||||||
@click="transitionFee('confirm')"
|
@click="transitionFee('confirm')"
|
||||||
><Check :size="16" />确认费用</el-button>
|
><Check :size="16" />确认费用</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canConfirm && canSend && snapshot?.feeStatus === 'CALCULATED'"
|
v-if="!displayOnly && canConfirm && canSend && snapshot?.feeStatus === 'CALCULATED'"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="Boolean(feeAction)"
|
:loading="Boolean(feeAction)"
|
||||||
:disabled="Boolean(feeAction)"
|
:disabled="Boolean(feeAction)"
|
||||||
@click="confirmAndPush"
|
@click="confirmAndPush"
|
||||||
><Send :size="16" />确认推送费用</el-button>
|
><Send :size="16" />确认推送费用</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canSend && snapshot?.feeStatus === 'CONFIRMED'"
|
v-if="!displayOnly && canSend && snapshot?.feeStatus === 'CONFIRMED'"
|
||||||
data-testid="send-fee-button"
|
data-testid="send-fee-button"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="feeAction === 'send'"
|
:loading="feeAction === 'send'"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ const pendingWeight: WeightResultResource = {
|
|||||||
adjustedAt: null,
|
adjustedAt: null,
|
||||||
confirmedBy: null,
|
confirmedBy: null,
|
||||||
confirmedAt: null,
|
confirmedAt: null,
|
||||||
|
palletSequence: 1,
|
||||||
|
blockId: '70001',
|
||||||
|
slotNo: 1,
|
||||||
|
reservationId: '71001',
|
||||||
|
locationId: '72001',
|
||||||
|
locationCode: 'A01',
|
||||||
|
sharedOverflow: false,
|
||||||
|
slotStatus: 'BOUND',
|
||||||
version: 0,
|
version: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +79,24 @@ const workspace: WeightWorkspaceResource = {
|
|||||||
actualGrossWeightKg: null,
|
actualGrossWeightKg: null,
|
||||||
actualNetWeightKg: null,
|
actualNetWeightKg: null,
|
||||||
weightResults: [pendingWeight],
|
weightResults: [pendingWeight],
|
||||||
|
palletLocationGuide: {
|
||||||
|
blockId: '70001',
|
||||||
|
reservedSlotCount: 1,
|
||||||
|
actualPalletCount: 1,
|
||||||
|
reservedEmptySlotCount: 0,
|
||||||
|
slots: [{
|
||||||
|
slotNo: 1,
|
||||||
|
reservationId: '71001',
|
||||||
|
locationId: '72001',
|
||||||
|
locationCode: 'A01',
|
||||||
|
sharedOverflow: false,
|
||||||
|
slotStatus: 'BOUND',
|
||||||
|
boundWeightResultId: '30001',
|
||||||
|
palletSequence: 1,
|
||||||
|
palletId: '50001',
|
||||||
|
palletCode: 'TP-001',
|
||||||
|
}],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function palletPage(records = [{
|
function palletPage(records = [{
|
||||||
@@ -205,9 +231,137 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(wrapper.text()).not.toContain('raw-0')
|
expect(wrapper.text()).not.toContain('raw-0')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders server-bound pallet sequence, target location, and pending putaway status ahead of OCR facts', async () => {
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const card = wrapper.get('.weight-card')
|
||||||
|
expect(card.text()).toContain('第 1 托')
|
||||||
|
expect(card.text()).toContain('目标库位')
|
||||||
|
expect(card.text()).toContain('A01')
|
||||||
|
expect(card.text()).toContain('整票预留库位')
|
||||||
|
expect(card.text()).toContain('待上架')
|
||||||
|
const putawayGuide = card.get('.weight-card__putaway').element
|
||||||
|
expect(putawayGuide.compareDocumentPosition(
|
||||||
|
card.get('.weight-card__value').element,
|
||||||
|
) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the authoritative whole-inbound reservation summary and an empty reserved slot', async () => {
|
||||||
|
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||||
|
...workspace,
|
||||||
|
palletLocationGuide: {
|
||||||
|
blockId: '70001',
|
||||||
|
reservedSlotCount: 3,
|
||||||
|
actualPalletCount: 2,
|
||||||
|
reservedEmptySlotCount: 1,
|
||||||
|
slots: [
|
||||||
|
{ slotNo: 1, reservationId: '71001', locationId: '72001', locationCode: 'A01', sharedOverflow: false, slotStatus: 'BOUND', boundWeightResultId: '30001', palletSequence: 1, palletId: '50001', palletCode: 'TP-001' },
|
||||||
|
{ slotNo: 2, reservationId: '71002', locationId: '72002', locationCode: 'A02', sharedOverflow: false, slotStatus: 'TRANSFERRED', boundWeightResultId: '30002', palletSequence: 2, palletId: '50002', palletCode: 'TP-002' },
|
||||||
|
{ slotNo: 3, reservationId: '71003', locationId: '72003', locationCode: 'A03', sharedOverflow: false, slotStatus: 'RESERVED_EMPTY', boundWeightResultId: null, palletSequence: null, palletId: null, palletCode: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const guide = wrapper.get('.putaway-guide')
|
||||||
|
expect(guide.text()).toContain('整票预留库位')
|
||||||
|
expect(guide.text()).toContain('A01 / A02 / A03')
|
||||||
|
expect(guide.text()).toContain('预留槽位数3')
|
||||||
|
expect(guide.text()).toContain('实际托数2')
|
||||||
|
expect(guide.text()).toContain('预留空位数1')
|
||||||
|
expect(guide.text()).toContain('A03预留空位本票预留空位')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an explicit whole-inbound unassigned state when no guide exists', async () => {
|
||||||
|
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, palletLocationGuide: null })
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('尚未分配整票预留库位')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks A0 as a temporary overflow location from the server flag', async () => {
|
||||||
|
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||||
|
...workspace,
|
||||||
|
weightResults: [{ ...pendingWeight, locationCode: 'A0', sharedOverflow: true }],
|
||||||
|
palletLocationGuide: {
|
||||||
|
...workspace.palletLocationGuide!,
|
||||||
|
slots: [{
|
||||||
|
...workspace.palletLocationGuide!.slots[0]!,
|
||||||
|
locationCode: 'A0',
|
||||||
|
sharedOverflow: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('A0 临时溢出位')
|
||||||
|
expect(wrapper.get('.weight-card__putaway').classes()).toContain('is-overflow')
|
||||||
|
expect(wrapper.get('.putaway-guide__slots li').classes()).toContain('is-overflow')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['TRANSFERRED', '已上架'],
|
||||||
|
['RELEASED', '已释放'],
|
||||||
|
['RESERVED_EMPTY', '预留空位'],
|
||||||
|
] as const)('renders the authoritative %s slot status as %s', async (slotStatus, label) => {
|
||||||
|
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||||
|
...workspace,
|
||||||
|
weightResults: [{ ...pendingWeight, slotStatus }],
|
||||||
|
})
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(label)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an explicit unassigned state when the server has not bound a slot', async () => {
|
||||||
|
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||||
|
...workspace,
|
||||||
|
weightResults: [{
|
||||||
|
...pendingWeight,
|
||||||
|
palletSequence: null,
|
||||||
|
blockId: null,
|
||||||
|
slotNo: null,
|
||||||
|
reservationId: null,
|
||||||
|
locationId: null,
|
||||||
|
locationCode: null,
|
||||||
|
sharedOverflow: null,
|
||||||
|
slotStatus: null,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('托盘未分配')
|
||||||
|
expect(wrapper.text()).toContain('目标库位未分配')
|
||||||
|
})
|
||||||
|
|
||||||
it('deletes a pending image with its optimistic-lock version', async () => {
|
it('deletes a pending image with its optimistic-lock version', async () => {
|
||||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||||
vi.mocked(deleteWeightResult).mockResolvedValue(undefined)
|
vi.mocked(deleteWeightResult).mockResolvedValue(undefined)
|
||||||
|
vi.mocked(getWeightWorkspace)
|
||||||
|
.mockResolvedValueOnce(workspace)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
...workspace,
|
||||||
|
weightResults: [],
|
||||||
|
palletLocationGuide: {
|
||||||
|
...workspace.palletLocationGuide!,
|
||||||
|
actualPalletCount: 0,
|
||||||
|
reservedEmptySlotCount: 1,
|
||||||
|
slots: [{
|
||||||
|
...workspace.palletLocationGuide!.slots[0]!,
|
||||||
|
slotStatus: 'RESERVED_EMPTY',
|
||||||
|
boundWeightResultId: null,
|
||||||
|
palletSequence: null,
|
||||||
|
palletId: null,
|
||||||
|
palletCode: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
const wrapper = mountPanel()
|
const wrapper = mountPanel()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -215,6 +369,48 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0)
|
expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0)
|
||||||
|
expect(confirm).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('对应库位仍为本票保留,不会释放给其他订单'),
|
||||||
|
'删除称重图片',
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
expect(getWeightWorkspace).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wrapper.findAll('.weight-card')).toHaveLength(0)
|
||||||
|
expect(wrapper.get('.putaway-guide').text()).toContain('预留空位数1')
|
||||||
|
expect(wrapper.get('.putaway-guide').text()).toContain('A01预留空位本票预留空位')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the image and server workspace visible when deletion fails', async () => {
|
||||||
|
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||||
|
const errorMessage = vi.spyOn(ElMessage, 'error')
|
||||||
|
vi.mocked(deleteWeightResult).mockRejectedValueOnce(
|
||||||
|
new ApiResponseError('无权删除当前称重图片', 'A0403', 403),
|
||||||
|
)
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('button[aria-label="删除称重图片 1"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('.weight-card')).toHaveLength(1)
|
||||||
|
expect(wrapper.text()).toContain('A01')
|
||||||
|
expect(getWeightWorkspace).toHaveBeenCalledTimes(1)
|
||||||
|
expect(errorMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
message: '当前账号无权访问该数据,请联系管理员确认权限',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a retryable workspace load failure without fabricating putaway guidance', async () => {
|
||||||
|
vi.mocked(getWeightWorkspace).mockRejectedValueOnce(
|
||||||
|
new ApiResponseError('称重工作区暂不可用', 'A0500', 503),
|
||||||
|
)
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('称重数据加载失败')
|
||||||
|
expect(wrapper.text()).toContain('称重工作区暂不可用')
|
||||||
|
expect(wrapper.find('.putaway-guide').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.putaway-guide-empty').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows only the key weights and allows direct confirmation', async () => {
|
it('shows only the key weights and allows direct confirmation', async () => {
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).toContain('货物重量')
|
expect(source).toContain('货物重量')
|
||||||
expect(source).toContain('palletTareWeightKg')
|
expect(source).toContain('palletTareWeightKg')
|
||||||
expect(source).toContain('netWeightKg')
|
expect(source).toContain('netWeightKg')
|
||||||
|
expect(source).toContain('row.palletSequence')
|
||||||
|
expect(source).toContain('row.locationCode')
|
||||||
|
expect(source).toContain('row.sharedOverflow')
|
||||||
|
expect(source).toContain('putawayStatusLabel(row.slotStatus)')
|
||||||
|
expect(source).toContain('workspace.palletLocationGuide.reservedSlotCount')
|
||||||
|
expect(source).toContain('workspace.palletLocationGuide.actualPalletCount')
|
||||||
|
expect(source).toContain('workspace.palletLocationGuide.reservedEmptySlotCount')
|
||||||
|
expect(source).toContain('本票预留空位')
|
||||||
|
expect(source).toContain('对应库位仍为本票保留,不会释放给其他订单')
|
||||||
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)")
|
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ import {
|
|||||||
getWeightWorkspace,
|
getWeightWorkspace,
|
||||||
uploadWeightImage,
|
uploadWeightImage,
|
||||||
} from '../api/weightOcr'
|
} from '../api/weightOcr'
|
||||||
import type { WeightPalletOption, WeightResultResource, WeightWorkspaceResource } from '../types'
|
import type {
|
||||||
|
WeightPalletOption,
|
||||||
|
WeightPutawaySlotStatus,
|
||||||
|
WeightResultResource,
|
||||||
|
WeightWorkspaceResource,
|
||||||
|
} from '../types'
|
||||||
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -180,6 +185,26 @@ function cardWeight(row: WeightResultResource) {
|
|||||||
return formatWeight(draft?.value ?? null, draft?.unit ?? 'KG')
|
return formatWeight(draft?.value ?? null, draft?.unit ?? 'KG')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function putawayStatusLabel(status: WeightResultResource['slotStatus']) {
|
||||||
|
return ({
|
||||||
|
RESERVED_EMPTY: '预留空位',
|
||||||
|
BOUND: '待上架',
|
||||||
|
TRANSFERRED: '已上架',
|
||||||
|
RELEASED: '已释放',
|
||||||
|
} as const)[status as Exclude<WeightResultResource['slotStatus'], null>] || '未分配'
|
||||||
|
}
|
||||||
|
|
||||||
|
function putawayStatusType(status: WeightResultResource['slotStatus']) {
|
||||||
|
if (status === 'TRANSFERRED') return 'success'
|
||||||
|
if (status === 'BOUND') return 'warning'
|
||||||
|
if (status === 'RELEASED') return 'info'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function guideSlotStatusType(status: WeightPutawaySlotStatus) {
|
||||||
|
return putawayStatusType(status)
|
||||||
|
}
|
||||||
|
|
||||||
function hasWeightDraftChanged(row: WeightResultResource) {
|
function hasWeightDraftChanged(row: WeightResultResource) {
|
||||||
const draft = weightDrafts.value[row.id]
|
const draft = weightDrafts.value[row.id]
|
||||||
if (!draft) return false
|
if (!draft) return false
|
||||||
@@ -403,8 +428,8 @@ async function removeResult(row: WeightResultResource) {
|
|||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
row.recognitionStatus === 'CONFIRMED'
|
row.recognitionStatus === 'CONFIRMED'
|
||||||
? '删除后将重新计算货物总重量,确认删除这张称重图片吗?'
|
? '删除后将重新计算货物总重量;对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?'
|
||||||
: '确认删除这张称重图片吗?',
|
: '对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?',
|
||||||
'删除称重图片',
|
'删除称重图片',
|
||||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
)
|
)
|
||||||
@@ -532,6 +557,41 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<section v-if="workspace?.palletLocationGuide" class="putaway-guide" aria-labelledby="putaway-guide-title">
|
||||||
|
<div class="putaway-guide__heading">
|
||||||
|
<div>
|
||||||
|
<span class="putaway-guide__eyebrow">整票上架引导</span>
|
||||||
|
<h4 id="putaway-guide-title">整票预留库位</h4>
|
||||||
|
</div>
|
||||||
|
<div class="putaway-guide__metrics" aria-label="整票库位汇总">
|
||||||
|
<div><span>预留库位</span><strong>{{ workspace.palletLocationGuide.slots.map((slot) => slot.locationCode).join(' / ') }}</strong></div>
|
||||||
|
<div><span>预留槽位数</span><strong>{{ workspace.palletLocationGuide.reservedSlotCount }}</strong></div>
|
||||||
|
<div><span>实际托数</span><strong>{{ workspace.palletLocationGuide.actualPalletCount }}</strong></div>
|
||||||
|
<div><span>预留空位数</span><strong>{{ workspace.palletLocationGuide.reservedEmptySlotCount }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="putaway-guide__slots" aria-label="整票预留槽位">
|
||||||
|
<li
|
||||||
|
v-for="slot in workspace.palletLocationGuide.slots"
|
||||||
|
:key="slot.reservationId"
|
||||||
|
:class="{ 'is-empty': slot.slotStatus === 'RESERVED_EMPTY', 'is-overflow': slot.sharedOverflow }"
|
||||||
|
>
|
||||||
|
<div class="putaway-guide__slot-title">
|
||||||
|
<strong>{{ slot.locationCode }}</strong>
|
||||||
|
<el-tag size="small" effect="light" :type="guideSlotStatusType(slot.slotStatus)">
|
||||||
|
{{ putawayStatusLabel(slot.slotStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<span v-if="slot.palletSequence != null">第 {{ slot.palletSequence }} 托 · {{ slot.palletCode || '托盘编码未记录' }}</span>
|
||||||
|
<span v-else>本票预留空位</span>
|
||||||
|
<small v-if="slot.sharedOverflow">A0 临时溢出位</small>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<div v-else-if="workspace" class="putaway-guide-empty" role="status">
|
||||||
|
<strong>尚未分配整票预留库位</strong>
|
||||||
|
<span>称重图片绑定库位后,将按服务端上架引导显示目标库位。</span>
|
||||||
|
</div>
|
||||||
<LoadErrorState
|
<LoadErrorState
|
||||||
v-if="palletError"
|
v-if="palletError"
|
||||||
title="可选托盘加载失败"
|
title="可选托盘加载失败"
|
||||||
@@ -579,6 +639,20 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
><Trash2 :size="16" /></el-button>
|
><Trash2 :size="16" /></el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<div class="weight-card__body">
|
<div class="weight-card__body">
|
||||||
|
<div class="weight-card__putaway" :class="{ 'is-overflow': row.sharedOverflow }">
|
||||||
|
<div class="weight-card__putaway-title">
|
||||||
|
<strong>{{ row.palletSequence == null ? '托盘未分配' : `第 ${row.palletSequence} 托` }}</strong>
|
||||||
|
<el-tag size="small" effect="light" :type="putawayStatusType(row.slotStatus)">
|
||||||
|
{{ putawayStatusLabel(row.slotStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="weight-card__location">
|
||||||
|
<span>目标库位</span>
|
||||||
|
<strong>{{ row.locationCode || '未分配' }}</strong>
|
||||||
|
</div>
|
||||||
|
<span v-if="row.sharedOverflow" class="weight-card__overflow">A0 临时溢出位</span>
|
||||||
|
<span v-else-if="row.locationCode" class="weight-card__normal-location">整票预留库位</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="weight-card__value"
|
class="weight-card__value"
|
||||||
@@ -675,13 +749,44 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
.weight-summary span { color: var(--cc-muted); font-size: 12px; }
|
.weight-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||||
.weight-summary strong { color: #067647; font-size: 16px; }
|
.weight-summary strong { color: #067647; font-size: 16px; }
|
||||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||||
.weight-action-error { margin-bottom: 14px; }
|
.weight-action-error { margin-bottom: 14px; }
|
||||||
|
.putaway-guide { margin: 14px 0 18px; padding: 16px; border: 1px solid #84caff; border-radius: 8px; background: #f5fbff; }
|
||||||
|
.putaway-guide__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
||||||
|
.putaway-guide__eyebrow { color: #175cd3; font-size: 11px; font-weight: 700; letter-spacing: .08em; }
|
||||||
|
.putaway-guide h4 { margin: 3px 0 0; color: #102a56; font-size: 18px; }
|
||||||
|
.putaway-guide__metrics { display: grid; grid-template-columns: minmax(180px, 1.8fr) repeat(3, minmax(90px, .6fr)); gap: 10px; flex: 1; max-width: 820px; }
|
||||||
|
.putaway-guide__metrics div { display: grid; min-width: 0; gap: 3px; padding: 9px 11px; border-radius: 6px; background: #fff; box-shadow: 0 1px 2px rgb(16 24 40 / 6%); }
|
||||||
|
.putaway-guide__metrics span { color: #667085; font-size: 11px; }
|
||||||
|
.putaway-guide__metrics strong { color: #101828; font-size: 15px; overflow-wrap: anywhere; }
|
||||||
|
.putaway-guide__slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||||
|
.putaway-guide__slots li { display: grid; gap: 5px; min-width: 0; padding: 11px; border: 1px solid #b2ddff; border-radius: 6px; background: #fff; }
|
||||||
|
.putaway-guide__slots li.is-empty { border-style: dashed; background: #f9fafb; }
|
||||||
|
.putaway-guide__slots li.is-overflow { border-color: #fec84b; background: #fffaeb; }
|
||||||
|
.putaway-guide__slot-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.putaway-guide__slot-title strong { color: #1849a9; font-size: 20px; }
|
||||||
|
.putaway-guide__slots li.is-overflow .putaway-guide__slot-title strong { color: #93370d; }
|
||||||
|
.putaway-guide__slots li > span { color: #475467; font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
.putaway-guide__slots small { width: fit-content; color: #93370d; font-weight: 600; }
|
||||||
|
.putaway-guide-empty { display: grid; gap: 3px; margin: 14px 0 18px; padding: 13px 15px; border: 1px dashed #98a2b3; border-radius: 8px; background: #f9fafb; }
|
||||||
|
.putaway-guide-empty strong { color: #344054; font-size: 14px; }
|
||||||
|
.putaway-guide-empty span { color: #667085; font-size: 12px; }
|
||||||
.weight-list { min-height: 0; margin-top: 14px; }
|
.weight-list { min-height: 0; margin-top: 14px; }
|
||||||
.weight-empty { display: flex; min-height: 0; align-items: center; gap: 10px; padding: 4px 0 0; color: var(--cc-muted); }
|
.weight-empty { display: flex; min-height: 0; align-items: center; gap: 10px; padding: 4px 0 0; color: var(--cc-muted); }
|
||||||
.weight-empty strong { color: #344054; font-size: 14px; }
|
.weight-empty strong { color: #344054; font-size: 14px; }
|
||||||
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
|
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
|
||||||
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||||
.weight-card__body { padding: 12px; text-align: center; }
|
.weight-card__body { padding: 12px; text-align: center; }
|
||||||
|
.weight-card__putaway { display: grid; gap: 8px; margin: -12px -12px 12px; padding: 12px; border-bottom: 1px solid #b2ddff; background: #eff8ff; text-align: left; }
|
||||||
|
.weight-card__putaway.is-overflow { border-color: #fec84b; background: #fffaeb; }
|
||||||
|
.weight-card__putaway-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.weight-card__putaway-title strong { color: #1849a9; font-size: 15px; }
|
||||||
|
.weight-card__putaway.is-overflow .weight-card__putaway-title strong { color: #93370d; }
|
||||||
|
.weight-card__location { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||||
|
.weight-card__location span { color: #475467; font-size: 12px; }
|
||||||
|
.weight-card__location strong { color: #101828; font-size: 22px; line-height: 1; overflow-wrap: anywhere; }
|
||||||
|
.weight-card__overflow, .weight-card__normal-location { width: fit-content; padding: 2px 7px; border-radius: 999px; font-size: 11px; font-weight: 600; }
|
||||||
|
.weight-card__overflow { background: #fef0c7; color: #93370d; }
|
||||||
|
.weight-card__normal-location { background: #d1e9ff; color: #175cd3; }
|
||||||
.weight-delete { position: absolute; z-index: 2; top: 8px; right: 8px; width: 30px; height: 30px; padding: 0; border: 1px solid rgb(255 255 255 / 82%); background: rgb(255 255 255 / 92%); box-shadow: 0 1px 3px rgb(16 24 40 / 16%); }
|
.weight-delete { position: absolute; z-index: 2; top: 8px; right: 8px; width: 30px; height: 30px; padding: 0; border: 1px solid rgb(255 255 255 / 82%); background: rgb(255 255 255 / 92%); box-shadow: 0 1px 3px rgb(16 24 40 / 16%); }
|
||||||
.weight-card__value { width: 100%; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: center; }
|
.weight-card__value { width: 100%; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: center; }
|
||||||
.weight-card__value strong { color: #101828; font-size: 17px; }
|
.weight-card__value strong { color: #101828; font-size: 17px; }
|
||||||
@@ -708,6 +813,9 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
.pallet-picker :deep(.el-select) { width: 100%; }
|
.pallet-picker :deep(.el-select) { width: 100%; }
|
||||||
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||||
.weight-summary-group { width: 100%; justify-content: space-between; }
|
.weight-summary-group { width: 100%; justify-content: space-between; }
|
||||||
|
.putaway-guide__heading { flex-direction: column; }
|
||||||
|
.putaway-guide__metrics { width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.putaway-guide__metrics div:first-child { grid-column: 1 / -1; }
|
||||||
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
export type WeightRecognitionStatus = 'PENDING_CONFIRM' | 'NEEDS_MANUAL' | 'CONFIRMED' | string
|
export type WeightRecognitionStatus = 'PENDING_CONFIRM' | 'NEEDS_MANUAL' | 'CONFIRMED' | string
|
||||||
|
export type WeightPutawaySlotStatus = 'RESERVED_EMPTY' | 'BOUND' | 'TRANSFERRED' | 'RELEASED'
|
||||||
|
|
||||||
|
export interface WeightPalletLocationSlot {
|
||||||
|
slotNo: number
|
||||||
|
reservationId: string
|
||||||
|
locationId: string
|
||||||
|
locationCode: string
|
||||||
|
sharedOverflow: boolean
|
||||||
|
slotStatus: WeightPutawaySlotStatus
|
||||||
|
boundWeightResultId: string | null
|
||||||
|
palletSequence: number | null
|
||||||
|
palletId: string | null
|
||||||
|
palletCode: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeightPalletLocationGuide {
|
||||||
|
blockId: string
|
||||||
|
reservedSlotCount: number
|
||||||
|
actualPalletCount: number
|
||||||
|
reservedEmptySlotCount: number
|
||||||
|
slots: WeightPalletLocationSlot[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface WeightResultResource {
|
export interface WeightResultResource {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,6 +45,14 @@ export interface WeightResultResource {
|
|||||||
adjustedAt: string | null
|
adjustedAt: string | null
|
||||||
confirmedBy: string | null
|
confirmedBy: string | null
|
||||||
confirmedAt: string | null
|
confirmedAt: string | null
|
||||||
|
palletSequence: number | null
|
||||||
|
blockId: string | null
|
||||||
|
slotNo: number | null
|
||||||
|
reservationId: string | null
|
||||||
|
locationId: string | null
|
||||||
|
locationCode: string | null
|
||||||
|
sharedOverflow: boolean | null
|
||||||
|
slotStatus: WeightPutawaySlotStatus | null
|
||||||
version: number
|
version: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +63,7 @@ export interface WeightWorkspaceResource {
|
|||||||
actualGrossWeightKg: number | null
|
actualGrossWeightKg: number | null
|
||||||
actualNetWeightKg: number | null
|
actualNetWeightKg: number | null
|
||||||
weightResults: WeightResultResource[]
|
weightResults: WeightResultResource[]
|
||||||
|
palletLocationGuide: WeightPalletLocationGuide | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeightPalletOption {
|
export interface WeightPalletOption {
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/p
|
|||||||
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/routes.ts'), 'utf8')
|
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/routes.ts'), 'utf8')
|
||||||
|
|
||||||
describe('UnifiedOrdersView contract', () => {
|
describe('UnifiedOrdersView contract', () => {
|
||||||
it('blocks confirm-send when the customer has already confirmed the fee', () => {
|
it('shows the server confirmation lock without reading a stale fee snapshot', () => {
|
||||||
expect(source).toContain("snapshot.customerConfirmationStatus === 'CONFIRMED'")
|
const openDialogSource = source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))
|
||||||
expect(source).toContain('客户已确认该费用,费用已锁定,不允许再修改或重新发送')
|
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
|
||||||
expect(source).toContain('async function openFeeConfirmDialog()')
|
expect(openDialogSource).not.toContain('getLatestInboundFee')
|
||||||
|
expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('only exposes the six product main statuses', () => {
|
it('only exposes the six product main statuses', () => {
|
||||||
@@ -391,13 +392,13 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
it('confirms customer billing from the saved outer-carton totals', () => {
|
it('confirms customer billing from the saved outer-carton totals', () => {
|
||||||
expect(source).toContain('const outerTotals = measurementTotals(outerMeasurementRows.value)')
|
expect(source).toContain('const outerTotals = measurementTotals(outerMeasurementRows.value)')
|
||||||
expect(source).toContain("ElMessage.warning('外箱规数据不完整,请先保存外箱规尺寸和计算规则')")
|
expect(source).toContain("ElMessage.warning('外箱规数据不完整,请先保存外箱规尺寸和计算规则')")
|
||||||
expect(source).toContain("weightSource: 'OUTER'")
|
expect(source).toContain("weightSource: 'ACTUAL'")
|
||||||
expect(source).toContain("volumeSource: 'OUTER'")
|
expect(source).toContain("volumeSource: 'ACTUAL'")
|
||||||
expect(source).not.toContain('测量审核通过后才能确认并发送费用')
|
expect(source).not.toContain('测量审核通过后才能确认并发送费用')
|
||||||
expect(source).not.toContain("reviewStatus !== 'APPROVED'")
|
expect(source).not.toContain("reviewStatus !== 'APPROVED'")
|
||||||
expect(source).toContain('const hasActualMeasurementData = computed(() => (measurementDetail.value?.items?.length ?? 0) > 0)')
|
expect(source).toContain('const hasActualMeasurementData = computed(() => (measurementDetail.value?.items?.length ?? 0) > 0)')
|
||||||
expect(source).toContain("暂无实际测量数据,不能确认并发送费用")
|
expect(source).toContain("暂无实际测量数据,不能确认并发送费用")
|
||||||
expect(source).toContain(':disabled="confirmFeeSending || measurementLoading || !hasActualMeasurementData || !canSendFeeForCurrentStatus"')
|
expect(source).not.toContain(':disabled="confirmFeeSending || measurementLoading || !hasActualMeasurementData"')
|
||||||
expect(source).toContain('selectAndSaveOuterMeasurementMode($event)')
|
expect(source).toContain('selectAndSaveOuterMeasurementMode($event)')
|
||||||
expect(source).toContain('const saved = await updateMeasurementCalculationMode(null, mode, ruleId)')
|
expect(source).toContain('const saved = await updateMeasurementCalculationMode(null, mode, ruleId)')
|
||||||
expect(source).toContain('coefficientMode: targetIds.has(String(item.id)) ? mode')
|
expect(source).toContain('coefficientMode: targetIds.has(String(item.id)) ? mode')
|
||||||
@@ -408,13 +409,22 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
expect(source).toContain('item.calculatedOuterLengthCm, item.calculatedOuterWidthCm, item.calculatedOuterHeightCm')
|
expect(source).toContain('item.calculatedOuterLengthCm, item.calculatedOuterWidthCm, item.calculatedOuterHeightCm')
|
||||||
expect(source).toContain('if (dimensions.every((dimension) => dimension == null)) return true')
|
expect(source).toContain('if (dimensions.every((dimension) => dimension == null)) return true')
|
||||||
expect(source).toContain('已保存并绑定计算规则,体积已重新计算')
|
expect(source).toContain('已保存并绑定计算规则,体积已重新计算')
|
||||||
expect(source).not.toContain('feeConfirmVolume.value = Number(summary.measurement?.actualVolumeCbm)')
|
expect(source).toContain('const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg)')
|
||||||
|
expect(source).toContain('const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg)')
|
||||||
|
expect(source).toContain('const actualVolume = Number(measurementDetail.value?.actualVolumeCbm)')
|
||||||
|
expect(source).toContain('feeConfirmWeight.value = measuredGrossWeight > 0')
|
||||||
|
expect(source).toContain('feeConfirmVolume.value = actualVolume > 0')
|
||||||
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
|
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
|
||||||
expect(sendFeeSource).not.toContain('saveInboundMeasurementDraft')
|
expect(sendFeeSource).not.toContain('saveInboundMeasurementDraft')
|
||||||
expect(sendFeeSource).not.toContain('updateMeasurementCalculationMode')
|
expect(sendFeeSource).not.toContain('updateMeasurementCalculationMode')
|
||||||
expect(source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))).not.toContain('getLatestInboundFee')
|
expect(source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))).not.toContain('getLatestInboundFee')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clears an invalidated measurement loading state before refreshing order details', () => {
|
||||||
|
const detailLoaderSource = source.slice(source.indexOf('async function loadDetail(force = false)'), source.indexOf('watch([page, pageSize]'))
|
||||||
|
expect(detailLoaderSource).toContain('measurementRequestSerial += 1\n measurementLoading.value = false')
|
||||||
|
})
|
||||||
|
|
||||||
it('restores the saved outer-carton rule and server-calculated dimensions on re-entry', () => {
|
it('restores the saved outer-carton rule and server-calculated dimensions on re-entry', () => {
|
||||||
expect(source).toContain('const savedModes = [...new Set(response.items.map((item) => item.coefficientMode)')
|
expect(source).toContain('const savedModes = [...new Set(response.items.map((item) => item.coefficientMode)')
|
||||||
expect(source).toContain('measurementSectionModes.outer = savedModes.length === 1')
|
expect(source).toContain('measurementSectionModes.outer = savedModes.length === 1')
|
||||||
@@ -502,6 +512,9 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
expect(source).toContain('InboundFeeDetailManager')
|
expect(source).toContain('InboundFeeDetailManager')
|
||||||
expect(source).toContain(':customer-id="String(detail.summary.tenantId)"')
|
expect(source).toContain(':customer-id="String(detail.summary.tenantId)"')
|
||||||
expect(source).toContain('compact')
|
expect(source).toContain('compact')
|
||||||
|
expect(source).toContain('display-only')
|
||||||
|
expect(source).toContain(':key="`fee-${normalizedInboundId}-${feePanelRevision}`"')
|
||||||
|
expect(source).toContain('feePanelRevision.value += 1')
|
||||||
expect(source).toContain('头程自动初算')
|
expect(source).toContain('头程自动初算')
|
||||||
expect(source).toContain('其他费用明细')
|
expect(source).toContain('其他费用明细')
|
||||||
expect(source).toContain('@changed="loadDetail(true)"')
|
expect(source).toContain('@changed="loadDetail(true)"')
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ const detailError = ref<ApiErrorDetails | null>(null)
|
|||||||
const declarationEditorOpen = ref(false)
|
const declarationEditorOpen = ref(false)
|
||||||
const activeDetailTab = ref('basic')
|
const activeDetailTab = ref('basic')
|
||||||
const automaticFee = ref<AutomaticFeeSummary | null>(null)
|
const automaticFee = ref<AutomaticFeeSummary | null>(null)
|
||||||
|
const feePanelRevision = ref(0)
|
||||||
const otherFeeSummary = ref<OtherFeeSummary>({ currency: 'CNY', approvedReceivable: 0, approvedPayable: 0 })
|
const otherFeeSummary = ref<OtherFeeSummary>({ currency: 'CNY', approvedReceivable: 0, approvedPayable: 0 })
|
||||||
const customerFeeTotal = computed(() => (
|
const customerFeeTotal = computed(() => (
|
||||||
(automaticFee.value?.feeStatus === 'SENT' ? Number(automaticFee.value.totalAmount || 0) : 0)
|
(automaticFee.value?.feeStatus === 'SENT' ? Number(automaticFee.value.totalAmount || 0) : 0)
|
||||||
@@ -198,9 +199,13 @@ const feeConfirmWeight = ref<number | null>(null)
|
|||||||
const feeConfirmVolume = ref<number | null>(null)
|
const feeConfirmVolume = ref<number | null>(null)
|
||||||
const feeConfirmRows = computed(() => {
|
const feeConfirmRows = computed(() => {
|
||||||
const summary = detail.value?.summary
|
const summary = detail.value?.summary
|
||||||
const measurement = summary?.measurement
|
const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg)
|
||||||
const actualWeight = Number(measurement?.actualGrossWeightKg)
|
const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg)
|
||||||
const actualVolume = Number(measurement?.actualVolumeCbm)
|
const measuredVolume = Number(measurementDetail.value?.actualVolumeCbm)
|
||||||
|
const actualWeight = measuredGrossWeight > 0
|
||||||
|
? measuredGrossWeight
|
||||||
|
: measuredNetWeight > 0 ? measuredNetWeight : null
|
||||||
|
const actualVolume = measuredVolume > 0 ? measuredVolume : null
|
||||||
const customerTotals = measurementTotals(customerMeasurementRows.value)
|
const customerTotals = measurementTotals(customerMeasurementRows.value)
|
||||||
return [
|
return [
|
||||||
{ source: '客户下单数据', count: customerTotals.count, weight: customerTotals.gross, volume: customerTotals.volume ?? summary?.expectedVolumeCbm },
|
{ source: '客户下单数据', count: customerTotals.count, weight: customerTotals.gross, volume: customerTotals.volume ?? summary?.expectedVolumeCbm },
|
||||||
@@ -435,11 +440,6 @@ const actualMeasurementRows = computed(() => (measurementDetail.value?.items ||
|
|||||||
calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual, false, false)
|
calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual, false, false)
|
||||||
)))
|
)))
|
||||||
const hasActualMeasurementData = computed(() => (measurementDetail.value?.items?.length ?? 0) > 0)
|
const hasActualMeasurementData = computed(() => (measurementDetail.value?.items?.length ?? 0) > 0)
|
||||||
const feeSendEditableStatuses = new Set([
|
|
||||||
'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED',
|
|
||||||
'WAIT_PRICING', 'FEE_CONFIRMING', 'WAIT_SPLIT',
|
|
||||||
])
|
|
||||||
const canSendFeeForCurrentStatus = computed(() => feeSendEditableStatuses.has(String(detail.value?.summary?.mainStatus || '')))
|
|
||||||
|
|
||||||
function updateMeasurementSectionMode(section: MeasurementSection, mode: MeasurementCalculationMode) {
|
function updateMeasurementSectionMode(section: MeasurementSection, mode: MeasurementCalculationMode) {
|
||||||
if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) {
|
if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) {
|
||||||
@@ -1181,7 +1181,7 @@ async function loadMeasurementDetails() {
|
|||||||
measurementLoadedKey.value = key
|
measurementLoadedKey.value = key
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (serial !== measurementRequestSerial) return
|
if (serial !== measurementRequestSerial) return
|
||||||
measurementDetail.value = null
|
if (!measurementDetail.value) measurementDetail.value = null
|
||||||
measurementError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
|
measurementError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
if (serial === measurementRequestSerial) measurementLoading.value = false
|
if (serial === measurementRequestSerial) measurementLoading.value = false
|
||||||
@@ -1415,10 +1415,6 @@ async function openFeeConfirmDialog() {
|
|||||||
ElMessage.warning('暂无实际测量数据,不能确认并发送费用')
|
ElMessage.warning('暂无实际测量数据,不能确认并发送费用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!canSendFeeForCurrentStatus.value) {
|
|
||||||
ElMessage.warning('订单已进入装柜流程,不能再修改或发送费用')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (confirmFeeSending.value) {
|
if (confirmFeeSending.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1432,12 +1428,17 @@ async function openFeeConfirmDialog() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const actualTotals = measurementTotals(actualMeasurementRows.value)
|
const actualTotals = measurementTotals(actualMeasurementRows.value)
|
||||||
const customerTotals = measurementTotals(customerMeasurementRows.value)
|
const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg)
|
||||||
const defaultWeight = Number(outerTotals.gross) > 0 ? Number(outerTotals.gross)
|
const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg)
|
||||||
: Number(actualTotals.gross) > 0 ? Number(actualTotals.gross)
|
const actualVolume = Number(measurementDetail.value?.actualVolumeCbm)
|
||||||
: Number(customerTotals.gross) > 0 ? Number(customerTotals.gross) : null
|
feeConfirmWeight.value = measuredGrossWeight > 0
|
||||||
feeConfirmWeight.value = defaultWeight
|
? measuredGrossWeight
|
||||||
feeConfirmVolume.value = Number(outerTotals.volume)
|
: measuredNetWeight > 0
|
||||||
|
? measuredNetWeight
|
||||||
|
: Number(actualTotals.gross) > 0 ? Number(actualTotals.gross) : null
|
||||||
|
feeConfirmVolume.value = actualVolume > 0
|
||||||
|
? actualVolume
|
||||||
|
: Number(actualTotals.volume) > 0 ? Number(actualTotals.volume) : null
|
||||||
feeConfirmOpen.value = true
|
feeConfirmOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1471,13 +1472,15 @@ async function executeConfirmAndSendFee() {
|
|||||||
let snapshot = await calculateInboundFee(tenantId, inboundId, {
|
let snapshot = await calculateInboundFee(tenantId, inboundId, {
|
||||||
selectedWeightKg,
|
selectedWeightKg,
|
||||||
selectedVolumeCbm,
|
selectedVolumeCbm,
|
||||||
weightSource: 'OUTER',
|
weightSource: 'ACTUAL',
|
||||||
volumeSource: 'OUTER',
|
volumeSource: 'ACTUAL',
|
||||||
}, createFeeActionIdempotencyKey('calculate'))
|
}, createFeeActionIdempotencyKey('calculate'))
|
||||||
snapshot = await confirmInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('confirm'))
|
snapshot = await confirmInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('confirm'))
|
||||||
if (snapshot.feeStatus === 'CONFIRMED') {
|
if (snapshot.feeStatus === 'CONFIRMED') {
|
||||||
await sendInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('send'))
|
const sent = await sendInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('send'))
|
||||||
ElMessage.success('费用已确认并发送给客户')
|
automaticFee.value = { feeStatus: sent.feeStatus, totalAmount: Number(sent.totalAmount || 0) }
|
||||||
|
feePanelRevision.value += 1
|
||||||
|
ElMessage.success('计费重已确认并发送给客户')
|
||||||
feeConfirmOpen.value = false
|
feeConfirmOpen.value = false
|
||||||
await loadDetail(true)
|
await loadDetail(true)
|
||||||
return
|
return
|
||||||
@@ -1814,7 +1817,9 @@ async function loadDetail(force = false) {
|
|||||||
const serial = ++detailRequestSerial
|
const serial = ++detailRequestSerial
|
||||||
detailLoadingKey = requestKey
|
detailLoadingKey = requestKey
|
||||||
measurementRequestSerial += 1
|
measurementRequestSerial += 1
|
||||||
measurementDetail.value = null
|
measurementLoading.value = false
|
||||||
|
// 刷新详情时保留现有测量数据,避免请求期间列表被清空。
|
||||||
|
if (!force) measurementDetail.value = null
|
||||||
measurementError.value = null
|
measurementError.value = null
|
||||||
measurementLoadedKey.value = ''
|
measurementLoadedKey.value = ''
|
||||||
measurementSectionModes.customer = 'ACTUAL'
|
measurementSectionModes.customer = 'ACTUAL'
|
||||||
@@ -2039,11 +2044,12 @@ onBeforeUnmount(() => {
|
|||||||
</dl>
|
</dl>
|
||||||
<h3 class="fee-section-title">头程自动初算</h3>
|
<h3 class="fee-section-title">头程自动初算</h3>
|
||||||
<InboundFeePanel
|
<InboundFeePanel
|
||||||
|
:key="`fee-${normalizedInboundId}-${feePanelRevision}`"
|
||||||
:customer-id="String(detail.summary.tenantId)"
|
:customer-id="String(detail.summary.tenantId)"
|
||||||
:inbound-id="normalizedInboundId"
|
:inbound-id="normalizedInboundId"
|
||||||
compact
|
compact
|
||||||
|
display-only
|
||||||
@loaded="automaticFee = $event"
|
@loaded="automaticFee = $event"
|
||||||
@changed="loadDetail(true)"
|
|
||||||
/>
|
/>
|
||||||
<h3 class="fee-section-title fee-section-title--secondary">其他费用明细</h3>
|
<h3 class="fee-section-title fee-section-title--secondary">其他费用明细</h3>
|
||||||
<InboundFeeDetailManager
|
<InboundFeeDetailManager
|
||||||
@@ -2106,11 +2112,10 @@ onBeforeUnmount(() => {
|
|||||||
v-if="can('firstleg:inbound:confirm-send-fee')"
|
v-if="can('firstleg:inbound:confirm-send-fee')"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="confirmFeeSending"
|
:loading="confirmFeeSending"
|
||||||
:disabled="confirmFeeSending || measurementLoading || !hasActualMeasurementData || !canSendFeeForCurrentStatus"
|
|
||||||
title="确认并发送费用"
|
title="确认并发送费用"
|
||||||
data-testid="confirm-send-fee-button"
|
data-testid="confirm-send-fee-button"
|
||||||
@click="openFeeConfirmDialog"
|
@click="openFeeConfirmDialog"
|
||||||
><Send :size="15" />确认费用发送</el-button>
|
><Send :size="15" />确认计费重发送</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-alert v-if="!can('platform:firstleg:inbound:measure:view')" title="当前账号缺少测量查看权限" type="warning" :closable="false" show-icon />
|
<el-alert v-if="!can('platform:firstleg:inbound:measure:view')" title="当前账号缺少测量查看权限" type="warning" :closable="false" show-icon />
|
||||||
@@ -2140,18 +2145,7 @@ onBeforeUnmount(() => {
|
|||||||
<el-option label="箱规尺寸" value="ADD" />
|
<el-option label="箱规尺寸" value="ADD" />
|
||||||
<el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" />
|
<el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select
|
<span class="measurement-rule-selector__hint">规则参数来源:客户管理</span>
|
||||||
v-if="measurementSectionModes.outer === 'MULTIPLY'"
|
|
||||||
class="measurement-rule-selector__rule"
|
|
||||||
:model-value="boundOuterMeasurementChargeRule?.id"
|
|
||||||
:disabled="Boolean(measurementStrategySavingId) || !activeMeasurementChargeRules.length"
|
|
||||||
placeholder="选择系数规则"
|
|
||||||
aria-label="箱规系数规则"
|
|
||||||
@change="selectAndSaveOuterMeasurementMode('MULTIPLY', $event)"
|
|
||||||
>
|
|
||||||
<el-option v-for="rule in activeMeasurementChargeRules" :key="rule.id" :label="`${rule.ruleCode} v${rule.ruleVersion}`" :value="rule.id" />
|
|
||||||
</el-select>
|
|
||||||
<span class="measurement-rule-selector__hint">规则参数来源:客户管理<span v-if="boundOuterMeasurementChargeRule"> · 当前绑定:{{ boundOuterMeasurementChargeRule.ruleCode }} v{{ boundOuterMeasurementChargeRule.ruleVersion }}</span></span>
|
|
||||||
</div>
|
</div>
|
||||||
<section class="measurement-section">
|
<section class="measurement-section">
|
||||||
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.actual" aria-label="实际测量数据计算方式" @change="updateMeasurementSectionMode('actual', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.actual" aria-label="实际测量数据计算方式" @change="updateMeasurementSectionMode('actual', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
||||||
|
|||||||
@@ -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)')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user