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:
2026-09-04 12:35:34 +08:00
24 changed files with 649 additions and 156 deletions
+1 -1
View File
@@ -50,7 +50,7 @@
| 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | | 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`,`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,8 +259,10 @@ 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)
return {
...raw, accountId, customerId: accountId, inboundId: optionalId(raw.inboundId),
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
coLoadGroupId: optionalId(raw.coLoadGroupId), coLoadGroupId: optionalId(raw.coLoadGroupId),
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
@@ -275,7 +277,8 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue
...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId), ...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
availablePackageCount: numberValue(member.availablePackageCount), 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, {
+6 -5
View File
@@ -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"
@@ -676,12 +750,43 @@ defineExpose({ reload: loadWorkspace })
.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)); }
} }
+31
View File
@@ -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>
+16
View File
@@ -11,6 +11,7 @@ const ElDialogStub = defineComponent({
showClose: Boolean, showClose: Boolean,
closeOnClickModal: Boolean, closeOnClickModal: Boolean,
closeOnPressEscape: Boolean, closeOnPressEscape: Boolean,
fullscreen: Boolean,
}, },
template: '<section data-testid="dialog"><slot name="header" /><slot /><slot name="footer" /></section>', template: '<section data-testid="dialog"><slot name="header" /><slot /><slot name="footer" /></section>',
}) })
@@ -65,10 +66,25 @@ describe('AppDialog', () => {
expect(wrapper.findAll('button')).toHaveLength(0) expect(wrapper.findAll('button')).toHaveLength(0)
}) })
it('provides opt-in fullscreen controls without changing ordinary dialogs', async () => {
const ordinary = mountDialog()
expect(ordinary.getComponent(ElDialogStub).props('fullscreen')).toBe(false)
expect(ordinary.find('button[aria-label="展开全屏"]').exists()).toBe(false)
const wrapper = mountDialog({ fullscreenable: true, defaultFullscreen: true })
expect(wrapper.getComponent(ElDialogStub).props('fullscreen')).toBe(true)
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
expect(wrapper.getComponent(ElDialogStub).props('fullscreen')).toBe(false)
expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true)
})
it('keeps the dialog chrome fixed while long content scrolls in the body', () => { it('keeps the dialog chrome fixed while long content scrolls in the body', () => {
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDialog.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDialog.vue'), 'utf8')
expect(source).toContain(':global(.app-dialog.el-dialog) { display: flex;') expect(source).toContain(':global(.app-dialog.el-dialog) { display: flex;')
expect(source).toContain('width: 100%; max-width: 100%; height: 100vh;')
expect(source).toContain(':global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto;') expect(source).toContain(':global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto;')
expect(source).toContain('max-height: calc(100svh - 20px)') expect(source).toContain('max-height: calc(100svh - 20px)')
}) })
+35 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { Maximize2, Minimize2 } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info' export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
@@ -22,6 +23,8 @@ const props = withDefaults(defineProps<{
closeOnPressEscape?: boolean closeOnPressEscape?: boolean
destroyOnClose?: boolean destroyOnClose?: boolean
lockWhileSubmitting?: boolean lockWhileSubmitting?: boolean
fullscreenable?: boolean
defaultFullscreen?: boolean
}>(), { }>(), {
width: 'min(560px, calc(100vw - 28px))', width: 'min(560px, calc(100vw - 28px))',
loading: false, loading: false,
@@ -38,15 +41,26 @@ const props = withDefaults(defineProps<{
closeOnPressEscape: true, closeOnPressEscape: true,
destroyOnClose: false, destroyOnClose: false,
lockWhileSubmitting: true, lockWhileSubmitting: true,
fullscreenable: false,
defaultFullscreen: false,
}) })
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const fullscreen = ref(props.defaultFullscreen)
const emit = defineEmits<{ const emit = defineEmits<{
confirm: [] confirm: []
cancel: [] cancel: []
}>() }>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading) const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
watch(open, (value) => {
if (value) fullscreen.value = props.defaultFullscreen
})
function toggleFullscreen() {
fullscreen.value = !fullscreen.value
}
function closeDialog() { function closeDialog() {
if (submittingLocked.value) return if (submittingLocked.value) return
open.value = false open.value = false
@@ -65,8 +79,23 @@ function closeDialog() {
:close-on-click-modal="closeOnClickModal && !submittingLocked" :close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked" :close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose" :destroy-on-close="destroyOnClose"
:fullscreen="fullscreenable && fullscreen"
> >
<template v-if="$slots.header" #header><slot name="header" /></template> <template v-if="$slots.header || fullscreenable" #header>
<div v-if="fullscreenable" class="app-dialog__header">
<div class="app-dialog__header-content">
<slot v-if="$slots.header" name="header" :fullscreen="fullscreen" :toggle-fullscreen="toggleFullscreen" />
<span v-else>{{ title }}</span>
</div>
<el-tooltip :content="fullscreen ? '退出全屏' : '展开全屏'" placement="bottom">
<el-button text :aria-label="fullscreen ? '退出全屏' : '展开全屏'" @click="toggleFullscreen">
<Minimize2 v-if="fullscreen" :size="18" />
<Maximize2 v-else :size="18" />
</el-button>
</el-tooltip>
</div>
<slot v-else name="header" />
</template>
<div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading"> <div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading">
<slot /> <slot />
</div> </div>
@@ -89,8 +118,12 @@ function closeDialog() {
<style scoped> <style scoped>
:global(.app-dialog.el-dialog) { display: flex; max-height: min(90vh, 900px); flex-direction: column; overflow: hidden; } :global(.app-dialog.el-dialog) { display: flex; max-height: min(90vh, 900px); flex-direction: column; overflow: hidden; }
:global(.app-dialog.el-dialog.is-fullscreen) { width: 100%; max-width: 100%; height: 100vh; max-height: 100vh; margin: 0 !important; border-radius: 0; }
:global(.app-dialog .el-dialog__header), :global(.app-dialog .el-dialog__footer) { flex: 0 0 auto; } :global(.app-dialog .el-dialog__header), :global(.app-dialog .el-dialog__footer) { flex: 0 0 auto; }
:global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; } :global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.app-dialog__header { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; color: var(--cc-text); font-size: 18px; font-weight: 600; line-height: 24px; }
.app-dialog__header-content { min-width: 0; flex: 1; }
.app-dialog__header > :deep(.el-button) { flex: 0 0 auto; margin-right: 24px; color: var(--cc-muted); }
.app-dialog__body { min-height: 0; } .app-dialog__body { min-height: 0; }
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; } .app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-dialog__footer :deep(.el-button) { margin-left: 0; } .app-dialog__footer :deep(.el-button) { margin-left: 0; }