Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts: # AGENTS.md # src/modules/container-loading/pages/GlobalSmartLoadingView.vue # src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
This commit is contained in:
@@ -46,11 +46,11 @@
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库、测量、客户箱规策略与计价主数据 | `modules/inbounds/measurement/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算 |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 财务与柜级公共费用分摊 | `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/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`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准。待审核/待测量可提前占位,但确认预装和启动 PDA 前必须达到 `WAIT_SPLIT`。人工加入允许跨渠道,智能推荐仍按柜次渠道;柜次容量和限重取服务端字段。同柜不可拆组作为单卡整组分页、分配和移除,前端不得拆成员或自行拼组,任一成员已拣时整组保留 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `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/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
|
||||
@@ -104,6 +104,8 @@ function item(raw: SmartLoadingItem): SmartLoadingItem {
|
||||
packageCount: numberValue(raw.packageCount),
|
||||
pickedPackageCount: numberValue(raw.pickedPackageCount),
|
||||
minPackageCount: numberValue(raw.minPackageCount, 1),
|
||||
totalPackageCount: numberValue(raw.totalPackageCount, raw.packageCount),
|
||||
remainingPackageCount: numberValue(raw.remainingPackageCount),
|
||||
canAdjust: Boolean(raw.canAdjust), canRemove: Boolean(raw.canRemove),
|
||||
productQuantity: raw.productQuantity == null ? null : numberValue(raw.productQuantity),
|
||||
grossWeightKg: raw.grossWeightKg == null ? null : numberValue(raw.grossWeightKg),
|
||||
|
||||
@@ -123,11 +123,11 @@ describe('ContainersView', () => {
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('排柜')
|
||||
expect(wrapper.text()).toContain('智能装柜')
|
||||
expect(wrapper.text()).toContain('预装清单')
|
||||
expect(wrapper.text()).toContain('装柜清单')
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '排柜')!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '智能装柜')!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('预装清单'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click')
|
||||
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
|
||||
|
||||
@@ -135,7 +135,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
|
||||
{ key: 'containerType', type: 'select', label: '柜型', width: 140, placeholder: '请选择箱型', options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } },
|
||||
{ key: 'status', type: 'select', label: '排柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
|
||||
{ key: 'status', type: 'select', label: '装柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '装柜状态' } },
|
||||
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
@@ -536,7 +536,7 @@ onMounted(async () => {
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
|
||||
|
||||
@@ -255,6 +255,37 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).toContain('柜子数量2 个')
|
||||
})
|
||||
|
||||
it('selects only the double-clicked channel as a shortcut', async () => {
|
||||
const other = {
|
||||
...container,
|
||||
id: '9007199254740998',
|
||||
planId: '9007199254740999',
|
||||
cabinetNo: 'CB-2',
|
||||
containerNo: 'MSCU-2',
|
||||
channelCode: 'HDHK',
|
||||
channelName: '合德海卡包税',
|
||||
}
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [container, other], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([container, other])
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId }))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const hedeChannel = wrapper.findAllComponents(ElCheckboxButton).find((item) => item.text() === '合德')!
|
||||
await hedeChannel.find('input').setValue(true)
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.channel-tags .el-checkbox-button.is-checked')).toHaveLength(2)
|
||||
|
||||
await hedeChannel.trigger('dblclick')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.channel-tags .el-checkbox-button.is-checked').map((item) => item.text())).toEqual(['合德'])
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ channelGroupCodes: ['HEDE'] }))
|
||||
expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['HEDE'], page: 1, size: 200 })
|
||||
expect(wrapper.text()).toContain('MSCU-2')
|
||||
expect(wrapper.text()).not.toContain('MSCU-1')
|
||||
})
|
||||
|
||||
it('merges detailed pricing channels into the same confirmed display group', async () => {
|
||||
const parcelContainer = {
|
||||
...container,
|
||||
@@ -494,7 +525,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(document.body.textContent).toContain('平均单箱体积 待测量')
|
||||
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
|
||||
expect(document.body.textContent).toContain('订单渠道:慢船')
|
||||
expect(document.body.textContent).toContain('这是人工排柜,确认后仍会加入')
|
||||
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
|
||||
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
|
||||
;([...document.body.querySelectorAll('button')]
|
||||
.find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click()
|
||||
@@ -523,9 +554,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||
it('shows the package summary first and opens the editor from an icon', () => {
|
||||
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('class="package-count-summary"')
|
||||
expect(source).toContain('aria-label="调整箱数"')
|
||||
expect(source).toContain('v-if="(row.remainingPackageCount ?? 0) > 0"')
|
||||
expect(source).toContain('v-if="editingQuantityId === row.id"')
|
||||
expect(source).toContain(':controls="false"')
|
||||
expect(source).toContain('@blur="adjust(row)" @keyup.enter="adjust(row)"')
|
||||
expect(source).not.toContain('<Save')
|
||||
@@ -541,6 +575,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[aria-label="调整箱数"]').trigger('click')
|
||||
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)
|
||||
@@ -809,6 +845,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
expect(wrapper.text()).toContain('已拣 3 箱')
|
||||
expect(wrapper.findAll('.row-actions .el-button--danger')).toHaveLength(0)
|
||||
await wrapper.find('[aria-label="调整箱数"]').trigger('click')
|
||||
await flushPromises()
|
||||
const quantityInput = wrapper.findAllComponents(ElInputNumber).find((input) => input.props('modelValue') === 6)
|
||||
expect(quantityInput?.props('min')).toBe(3)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('移除未拣'))).toBe(false)
|
||||
@@ -832,11 +870,20 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('加入'))).toBe(false)
|
||||
})
|
||||
|
||||
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
|
||||
it('uses the server supplied total package count as the adjustment maximum', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]))
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
items: [{ ...plan.items[0], totalPackageCount: 10, remainingPackageCount: 4 }],
|
||||
})
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('使用:6')
|
||||
expect(wrapper.text()).toContain('未用:4')
|
||||
expect(wrapper.text()).not.toContain('共 10 箱')
|
||||
await wrapper.find('[aria-label="调整箱数"]').trigger('click')
|
||||
await flushPromises()
|
||||
const maxima = wrapper.findAllComponents(ElInputNumber).map((input) => input.props('max'))
|
||||
expect(maxima).toContain(10)
|
||||
})
|
||||
@@ -878,7 +925,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain('<el-checkbox-group :model-value="selectedChannelCodes"')
|
||||
expect(source).toContain('<el-checkbox-button v-for="channel in channelOptions"')
|
||||
expect(source).toContain('v-for="channel in channelOptions"')
|
||||
expect(source).toContain('@dblclick.stop.prevent="selectOnlyChannel(channel.code)"')
|
||||
expect(source).toContain('{{ channel.name }}')
|
||||
expect(source).toContain("ElMessage.warning('至少选择一个渠道')")
|
||||
expect(source).toContain('class="compact-summary" aria-label="已选渠道装柜统计"')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '../smartLoadingChannelGroups'
|
||||
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
const editingQuantityId = ref('')
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const containers = ref<SmartLoadingWorkbenchContainer[]>([])
|
||||
@@ -234,7 +235,7 @@ const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
|
||||
{ key: 'packageCount', label: '分配箱数', width: 100, slotName: 'packageCount' },
|
||||
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
||||
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
|
||||
{ key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
|
||||
]
|
||||
@@ -250,8 +251,12 @@ function candidateRow(source: object) { return source as SmartLoadingCandidateRo
|
||||
function itemRow(source: object) { return source as SmartLoadingInboundItem }
|
||||
function adjustmentMax(source: object) {
|
||||
const item = itemRow(source)
|
||||
const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0
|
||||
return Math.max(item.minPackageCount, item.packageCount + remaining)
|
||||
return Math.max(item.minPackageCount, item.totalPackageCount || item.packageCount)
|
||||
}
|
||||
function startQuantityEdit(source: object) {
|
||||
const item = itemRow(source)
|
||||
quantityDrafts[item.id] = item.packageCount
|
||||
editingQuantityId.value = item.id
|
||||
}
|
||||
function displayCabinetNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
|
||||
if (!container) return '待生成'
|
||||
@@ -440,6 +445,12 @@ async function selectChannels(codes: string[]) {
|
||||
candidatePage.value = 1
|
||||
await loadWorkbenchData()
|
||||
}
|
||||
async function selectOnlyChannel(code: string) {
|
||||
if (selectedChannelCodes.value.length === 1 && selectedChannelCodes.value[0] === code) return
|
||||
selectedChannelCodes.value = [code]
|
||||
candidatePage.value = 1
|
||||
await loadWorkbenchData()
|
||||
}
|
||||
async function applyFilters(model: QueryModel) {
|
||||
appliedQuery.value = {
|
||||
...model,
|
||||
@@ -539,6 +550,7 @@ async function adjust(source: object) {
|
||||
await mutate(async () => {
|
||||
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
|
||||
await loadCandidateData()
|
||||
editingQuantityId.value = ''
|
||||
ElMessage.success('箱数已调整')
|
||||
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
|
||||
}
|
||||
@@ -623,7 +635,14 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<div v-if="channelOptions.length" class="channel-summary-row">
|
||||
<el-checkbox-group :model-value="selectedChannelCodes" class="channel-tags" aria-label="渠道筛选,至少选择一项" @change="selectChannels($event as string[])">
|
||||
<span class="channel-tags__label">渠道</span>
|
||||
<el-checkbox-button v-for="channel in channelOptions" :key="channel.code" :label="channel.code" :value="channel.code">{{ channel.name }}</el-checkbox-button>
|
||||
<el-checkbox-button
|
||||
v-for="channel in channelOptions"
|
||||
:key="channel.code"
|
||||
:label="channel.code"
|
||||
:value="channel.code"
|
||||
title="双击仅选择此渠道"
|
||||
@dblclick.stop.prevent="selectOnlyChannel(channel.code)"
|
||||
>{{ channel.name }}</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
<dl class="compact-summary" aria-label="已选渠道装柜统计">
|
||||
<div><dt>候选明细</dt><dd><strong>{{ candidateTotal }}</strong> 条</dd></div>
|
||||
@@ -761,7 +780,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<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 #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" :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 #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||
</DataTable>
|
||||
@@ -775,7 +794,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
|
||||
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ selectedContainer?.channelName || '—' }}</p>
|
||||
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工排柜,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
|
||||
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
|
||||
@@ -845,6 +864,10 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
|
||||
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
|
||||
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
|
||||
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.package-count-summary strong { font-size: 13px; font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
|
||||
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
|
||||
@@ -49,6 +49,8 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
|
||||
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
|
||||
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;')
|
||||
expect(source).toContain('<template #goods="{ row }"><div class="goods-cell"><LineClampText')
|
||||
expect(source).toContain('<template #goodsSummary="{ row }"><LineClampText')
|
||||
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
|
||||
})
|
||||
|
||||
@@ -109,7 +111,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 2)
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 3)
|
||||
expect(wrapper.text()).not.toContain('排柜操作失败')
|
||||
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||||
})
|
||||
|
||||
it('uses bordered row actions and has no manual preload push', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, ListChecks, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ArrowLeft, ListChecks, Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -33,6 +33,7 @@ const mutating = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const noDraft = ref(false)
|
||||
const editingQuantityId = ref('')
|
||||
const assignOpen = ref(false)
|
||||
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
@@ -141,7 +142,7 @@ const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
|
||||
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, showOverflowTooltip: true },
|
||||
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, slotName: 'goodsSummary' },
|
||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
|
||||
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
|
||||
{ key: 'averageUnitVolumeCbm', label: '平均单箱体积', width: 145, slotName: 'averageUnitVolume' },
|
||||
@@ -152,6 +153,15 @@ function formatNumber(value: number, digits = 3) { return Number.isFinite(value)
|
||||
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
||||
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
|
||||
function itemRow(source: object) { return source as SmartLoadingInboundItem }
|
||||
function adjustmentMax(source: object) {
|
||||
const item = itemRow(source)
|
||||
return Math.max(item.minPackageCount, item.totalPackageCount || item.packageCount)
|
||||
}
|
||||
function startQuantityEdit(source: object) {
|
||||
const item = itemRow(source)
|
||||
quantityDrafts[item.id] = item.packageCount
|
||||
editingQuantityId.value = item.id
|
||||
}
|
||||
function explainCandidate(source: object) {
|
||||
const candidate = candidateRow(source)
|
||||
const volume = Number(candidate.averageUnitVolumeCbm || 0)
|
||||
@@ -200,7 +210,7 @@ async function load() {
|
||||
candidates.value = plan.value?.canAssign && channelGroup
|
||||
? (await listSmartLoadingCandidates({ channelGroupCodes: [channelGroup.code], warehouseId: container.value.warehouseId, page: 1, size: 200 })).records
|
||||
: []
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function openOperationalList() {
|
||||
@@ -268,7 +278,7 @@ async function adjust(source: object) {
|
||||
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try { await acceptPlanWithGroups(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
|
||||
try { await acceptPlanWithGroups(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); editingQuantityId.value = ''; ElMessage.success('箱数已调整') }
|
||||
catch (error) { await handleMutationError(error, '调整失败') }
|
||||
finally { mutating.value = false }
|
||||
}
|
||||
@@ -324,7 +334,7 @@ onMounted(load)
|
||||
<template>
|
||||
<AdminPage>
|
||||
<section class="business-page workbench">
|
||||
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
|
||||
<section class="surface-panel workbench-command-bar" aria-label="装柜作业摘要">
|
||||
<div class="workbench-identities">
|
||||
<div class="workbench-identity">
|
||||
<span>柜次号</span>
|
||||
@@ -337,13 +347,13 @@ onMounted(load)
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
|
||||
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能装柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
</div>
|
||||
</section>
|
||||
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="loadError" title="智能装柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else>
|
||||
<LoadErrorState v-if="mutationError" title="排柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<section v-if="container?.planStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成状态">
|
||||
<el-tag type="success">装柜完成</el-tag>
|
||||
<strong>该柜次已完成装柜</strong>
|
||||
@@ -363,14 +373,15 @@ onMounted(load)
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else content="普通单票" /></template>
|
||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||
<template #packageCount="{ row }"><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 #goods="{ row }"><div class="goods-cell"><LineClampText :content="row.productNameZh || '未命名货物'" /><LineClampText class="goods-cell__sku" :content="row.skuCode || '无 SKU'" /></div></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
|
||||
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #goodsSummary="{ row }"><LineClampText :content="row.goodsSummary || '—'" /></template>
|
||||
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
|
||||
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
|
||||
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
|
||||
@@ -404,7 +415,14 @@ onMounted(load)
|
||||
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
|
||||
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
|
||||
.row-actions { min-width: 150px; }
|
||||
.goods-cell { display: grid; min-width: 0; gap: 2px; }
|
||||
.goods-cell :deep(.line-clamp-text) { width: 100%; font-weight: 600; }
|
||||
.goods-cell :deep(.goods-cell__sku.line-clamp-text) { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
|
||||
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
|
||||
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
|
||||
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.package-count-summary strong { font-size: 13px; font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
|
||||
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
|
||||
@@ -5,7 +5,7 @@ export const containerLoadingRoutes = [
|
||||
path: 'container-loading',
|
||||
name: 'container-loading',
|
||||
component: () => import('./pages/ContainersView.vue'),
|
||||
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
|
||||
meta: { title: '货柜管理', description: '维护货柜资料并进入智能装柜工作台', permission: 'platform:firstleg:container:list' },
|
||||
},
|
||||
{
|
||||
path: 'container-loading/:containerId',
|
||||
@@ -17,6 +17,6 @@ export const containerLoadingRoutes = [
|
||||
path: 'container-loading/:containerId/smart-loading',
|
||||
name: 'smart-loading-workbench',
|
||||
component: () => import('./pages/SmartLoadingWorkbenchView.vue'),
|
||||
meta: { title: '智能排柜', description: '生成、调整并确认可解释的装柜计划', permission: 'platform:firstleg:smart-loading:list' },
|
||||
meta: { title: '智能装柜', description: '生成、调整并确认可解释的装柜计划', permission: 'platform:firstleg:smart-loading:list' },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -42,6 +42,8 @@ export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingI
|
||||
loadSequence: loadSequences.length ? Math.min(...loadSequences) : null,
|
||||
pickedPackageCount,
|
||||
minPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.minPackageCount || 0), 0),
|
||||
totalPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.totalPackageCount || item.packageCount || 0), 0),
|
||||
remainingPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.remainingPackageCount || 0), 0),
|
||||
canAdjust: sourceItems.length === 1 && first.canAdjust,
|
||||
canRemove: sourceItems.every((item) => item.canRemove),
|
||||
sourceItems,
|
||||
|
||||
@@ -341,6 +341,8 @@ export interface SmartLoadingItem {
|
||||
version: number
|
||||
pickedPackageCount: number
|
||||
minPackageCount: number
|
||||
totalPackageCount?: number
|
||||
remainingPackageCount?: number
|
||||
canAdjust: boolean
|
||||
canRemove: boolean
|
||||
}
|
||||
|
||||
@@ -165,6 +165,15 @@ describe('InboundTaskList measurement scope', () => {
|
||||
expect(source).toContain("if (!value) return '—'")
|
||||
})
|
||||
|
||||
it('shows the expected arrival as a date in both measurement and review lists', () => {
|
||||
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain("date.toLocaleDateString('zh-CN')")
|
||||
expect(source).toContain('{{ formatDateOnly((row as InboundOrderSummary).expectedArrivalAt) }}')
|
||||
expect(source).toContain('{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}')
|
||||
expect(source).not.toContain('{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}')
|
||||
})
|
||||
|
||||
it('renders first-leg business types with the shared Chinese labels', () => {
|
||||
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
||||
|
||||
|
||||
@@ -119,6 +119,12 @@ function formatDate(value: string | null | undefined) {
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatDateOnly(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function dateParam(value: string | undefined, end = false) {
|
||||
return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined
|
||||
}
|
||||
@@ -381,7 +387,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
<template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></div></template>
|
||||
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
|
||||
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
|
||||
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||
<template #expectedArrivalAt="{ row }">{{ formatDateOnly((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||
<template #customerName="{ row }"><LineClampText :content="(row as InboundOrderSummary).customerName" /></template>
|
||||
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
|
||||
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('InboundMeasurementDialog', () => {
|
||||
expect(source).toContain('class="inbound-measurement-workspace-dialog"')
|
||||
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
|
||||
expect(source).toContain('height: 92vh')
|
||||
expect(source).toContain('padding: 10px 20px')
|
||||
expect(source).toContain('<WeightOcrPanel')
|
||||
expect(source).toContain('<InboundMeasurementPanel')
|
||||
expect(source).toContain('class="measurement-dialog-weight"')
|
||||
|
||||
@@ -130,7 +130,7 @@ function handleSubmitted() {
|
||||
|
||||
<style>
|
||||
.inbound-measurement-workspace-dialog.el-dialog { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__header { flex: 0 0 auto; margin: 0; padding: 18px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__header { flex: 0 0 auto; margin: 0; padding: 10px 20px; border-bottom: 1px solid var(--cc-border); }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__body { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px 24px; }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__footer { flex: 0 0 auto; padding: 14px 24px; border-top: 1px solid var(--cc-border); }
|
||||
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
|
||||
|
||||
@@ -814,7 +814,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
||||
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
|
||||
.measurement-alert .el-button { margin-top: 9px; }
|
||||
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.measurement-section { padding: 16px 20px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-section { padding: 16px 0px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-summary-section { display: grid; gap: 12px; border-top: 0; }
|
||||
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.measurement-summary-context { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px 20px; }
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('inbound measurement API', () => {
|
||||
|
||||
await expect(getInboundMeasurement('10001', '7001')).resolves.toEqual(detail)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/inbounds/7001/measurement', {
|
||||
params: { tenantId: '10001' },
|
||||
params: { tenantId: '10001', requestAt: expect.any(Number) },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ export async function getInboundMeasurement(customerId: string, inboundId: strin
|
||||
// items[].boxCode, machineNo, deviceMeasuredAt. Keep the response aligned with the backend OpenAPI.
|
||||
const response = await http.get<ApiEnvelope<InboundMeasurementDetail>, ApiEnvelope<InboundMeasurementDetail>>(
|
||||
measurementPath(inboundId),
|
||||
{ params: { tenantId: customerId } },
|
||||
// Calculation-mode saves use a sibling PUT endpoint. A cache may otherwise keep
|
||||
// returning the pre-save measurement and make a successful update look failed.
|
||||
{ params: { tenantId: customerId, requestAt: Date.now() } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -231,23 +231,18 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
expect(wrapper.text()).not.toContain('raw-0')
|
||||
})
|
||||
|
||||
it('renders server-bound pallet sequence, target location, and pending putaway status ahead of OCR facts', async () => {
|
||||
it('renders the server-bound location and status once in the compact card 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('库位A01 · 待上架')
|
||||
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()
|
||||
expect(card.find('.weight-card__putaway').exists()).toBe(false)
|
||||
expect(card.get('.weight-card__facts').text()).toContain('库位A01 · 待上架')
|
||||
})
|
||||
|
||||
it('renders the authoritative whole-inbound reservation summary and an empty reserved slot', async () => {
|
||||
it('keeps whole-inbound reservation guidance out of the panel and shows location on each weight card', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
palletLocationGuide: {
|
||||
@@ -265,21 +260,18 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
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预留空位本票预留空位')
|
||||
expect(wrapper.find('.putaway-guide').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('整票上架引导')
|
||||
expect(wrapper.get('.weight-card__facts').text()).toContain('库位A01 · 待上架')
|
||||
})
|
||||
|
||||
it('shows an explicit whole-inbound unassigned state when no guide exists', async () => {
|
||||
it('does not render a full-width placeholder when no whole-inbound guide exists', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, palletLocationGuide: null })
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('尚未分配整票预留库位')
|
||||
expect(wrapper.text()).not.toContain('尚未分配整票预留库位')
|
||||
expect(wrapper.find('.putaway-guide-empty').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks A0 as a temporary overflow location from the server flag', async () => {
|
||||
@@ -299,8 +291,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
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')
|
||||
expect(wrapper.get('.weight-card__facts').text()).toContain('库位A0 · 待上架 · A0 临时溢出位')
|
||||
expect(wrapper.find('.putaway-guide').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -336,8 +328,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('托盘未分配')
|
||||
expect(wrapper.text()).toContain('目标库位未分配')
|
||||
expect(wrapper.text()).not.toContain('托盘未分配')
|
||||
expect(wrapper.get('.weight-card__facts').text()).toContain('库位未分配')
|
||||
})
|
||||
|
||||
it('deletes a pending image with its optimistic-lock version', async () => {
|
||||
@@ -376,8 +368,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
)
|
||||
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预留空位本票预留空位')
|
||||
expect(wrapper.find('.putaway-guide').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the image and server workspace visible when deletion fails', async () => {
|
||||
|
||||
@@ -35,14 +35,15 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(source).toContain('货物重量')
|
||||
expect(source).toContain('palletTareWeightKg')
|
||||
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).not.toContain('class="putaway-guide"')
|
||||
expect(source).not.toContain('整票上架引导')
|
||||
expect(source).not.toContain('整票预留库位')
|
||||
expect(source).not.toContain('尚未分配整票预留库位')
|
||||
expect(source).not.toContain('class="weight-card__putaway"')
|
||||
expect(source).toContain('<div><dt>库位</dt><dd')
|
||||
expect(source).toContain('对应库位仍为本票保留,不会释放给其他订单')
|
||||
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)")
|
||||
})
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from '../api/weightOcr'
|
||||
import type {
|
||||
WeightPalletOption,
|
||||
WeightPutawaySlotStatus,
|
||||
WeightResultResource,
|
||||
WeightWorkspaceResource,
|
||||
} from '../types'
|
||||
@@ -194,17 +193,6 @@ function putawayStatusLabel(status: WeightResultResource['slotStatus']) {
|
||||
} 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) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
if (!draft) return false
|
||||
@@ -557,41 +545,6 @@ defineExpose({ reload: loadWorkspace })
|
||||
</LoadErrorState>
|
||||
|
||||
<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
|
||||
v-if="palletError"
|
||||
title="可选托盘加载失败"
|
||||
@@ -639,20 +592,6 @@ defineExpose({ reload: loadWorkspace })
|
||||
><Trash2 :size="16" /></el-button>
|
||||
</el-tooltip>
|
||||
<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
|
||||
type="button"
|
||||
class="weight-card__value"
|
||||
@@ -662,6 +601,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
<dl class="weight-card__facts">
|
||||
<div><dt>托盘编码</dt><dd :title="row.palletCode">{{ row.palletCode || '未记录编码' }}</dd></div>
|
||||
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
|
||||
<div><dt>库位</dt><dd :title="row.locationCode || '未分配'">{{ row.locationCode || '未分配' }}<template v-if="row.locationCode"> · {{ putawayStatusLabel(row.slotStatus) }}</template><template v-if="row.sharedOverflow"> · A0 临时溢出位</template></dd></div>
|
||||
<div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
|
||||
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
|
||||
<div><dt>货物重量</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
|
||||
@@ -750,43 +690,12 @@ defineExpose({ reload: loadWorkspace })
|
||||
.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; }
|
||||
.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-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-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__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-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; }
|
||||
@@ -813,9 +722,6 @@ defineExpose({ reload: loadWorkspace })
|
||||
.pallet-picker :deep(.el-select) { width: 100%; }
|
||||
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.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)); }
|
||||
}
|
||||
|
||||
|
||||
@@ -323,6 +323,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('仓库修改外箱(宽)')
|
||||
expect(source).toContain('仓库修改外箱(高)')
|
||||
expect(source).toContain('saveWarehouseOuterDimensions')
|
||||
expect(source).toContain("'MEASURE_REJECTED', 'WAIT_SPLIT'")
|
||||
expect(source).not.toContain('请至少输入一项需要修改的外箱尺寸')
|
||||
expect(source).toContain(".map((value) => value == null || value === '' ? null : Number(value))")
|
||||
expect(source).toContain('相关数值已重新计算')
|
||||
@@ -366,12 +367,11 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain("updateMeasurementSectionMode('actual', $event)")
|
||||
expect(source).not.toContain("updateMeasurementSectionMode('outer', $event)")
|
||||
expect(source.match(/@change="selectAndSaveOuterMeasurementMode\(\$event\)"/g)).toHaveLength(2)
|
||||
expect(source).toContain('@visible-change="handleOuterModeDropdownVisibleChange($event)"')
|
||||
expect(source).not.toContain('@visible-change="handleOuterModeDropdownVisibleChange($event)"')
|
||||
expect(source.match(/:disabled="Boolean\(measurementStrategySavingId\) \|\| !can\('platform:firstleg:inbound:measure:edit'\)"/g)).toHaveLength(2)
|
||||
expect(source).toContain('const previousMode = measurementSectionModes.outer')
|
||||
expect(source).toContain('if (!saved) measurementSectionModes.outer = previousMode')
|
||||
expect(source).toContain('const selectedSameMode = outerModeAtDropdownOpen.value === measurementSectionModes.outer')
|
||||
expect(source).toContain('await nextTick()')
|
||||
expect(source).not.toContain('handleOuterModeDropdownVisibleChange')
|
||||
expect(source).toContain('const saved = await updateMeasurementCalculationMode(null, mode, ruleId)')
|
||||
expect(source).toContain('calculatedMeasurementRow(row, measurementSectionModes.customer, true)')
|
||||
expect(source).toContain('calculatedMeasurementRow(source, measurementSectionModes.outer)')
|
||||
@@ -381,6 +381,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).not.toContain("!customerSource && mode !== 'ACTUAL'")
|
||||
expect(source).toContain('rawDimensions.map((value) => value + rule.edgeAdditionCm * 2)')
|
||||
expect(source).toContain('matchedSpecificationCoefficient(chargeRule, calculationSource)')
|
||||
expect(source).toContain(".find((item) => item.id === boundChargeRuleId && item.status === 'ACTIVE')")
|
||||
expect(source).toContain('listCustomerChargeRules(customerId, tenantId)')
|
||||
expect(source).toContain('matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))')
|
||||
expect(source).toContain('const selectedMode: MeasurementCalculationMode = measurementSectionModes.outer')
|
||||
@@ -405,15 +406,21 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('outerLengthCm: item.outerLengthCm')
|
||||
expect(source).toContain('outerWidthCm: item.outerWidthCm')
|
||||
expect(source).toContain('outerHeightCm: item.outerHeightCm')
|
||||
expect(source).toContain("String(item.chargeRuleId) === String(activeRule!.id)")
|
||||
expect(source).toContain('item.calculatedOuterLengthCm, item.calculatedOuterWidthCm, item.calculatedOuterHeightCm')
|
||||
expect(source).toContain('if (dimensions.every((dimension) => dimension == null)) return true')
|
||||
expect(source).toContain('const savedDraft = await saveInboundMeasurementDraft')
|
||||
expect(source).toContain('version: savedDraft.version')
|
||||
expect(source).toContain('waitForSavedMeasurementVersion(')
|
||||
expect(source).toContain('Number(refreshed.version) >= savedVersion')
|
||||
expect(source).toContain('setTimeout(resolve, 250)')
|
||||
expect(source).toContain("chargeRuleId: mode === 'MULTIPLY' ? activeRule!.id : null")
|
||||
expect(source).toContain('calculatedOuterLengthCm: null')
|
||||
expect(source).not.toContain('计算策略保存后回读不一致,请刷新后重试')
|
||||
expect(source).toContain('已保存并绑定计算规则,体积已重新计算')
|
||||
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')
|
||||
expect(source).not.toContain('const actualVolume = Number(measurementDetail.value?.actualVolumeCbm)')
|
||||
expect(source).toContain('feeConfirmWeight.value = Number(actualTotals.gross) > 0')
|
||||
expect(source).toContain('feeConfirmVolume.value = Number(actualTotals.volume) > 0')
|
||||
expect(source).toContain("{ source: '实际测量数据', count: actualTotals.count, weight: actualWeight, volume: actualVolume }")
|
||||
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
|
||||
expect(sendFeeSource).not.toContain('saveInboundMeasurementDraft')
|
||||
expect(sendFeeSource).not.toContain('updateMeasurementCalculationMode')
|
||||
|
||||
@@ -189,7 +189,6 @@ type MeasurementCalculationMode = 'ACTUAL' | 'ADD' | 'MULTIPLY'
|
||||
const measurementSectionModes = reactive<Record<MeasurementSection, MeasurementCalculationMode>>({
|
||||
customer: 'ACTUAL', outer: 'ACTUAL', actual: 'ACTUAL',
|
||||
})
|
||||
const outerModeAtDropdownOpen = ref<MeasurementCalculationMode | null>(null)
|
||||
const cargoImagePreviewUrls = new Set<string>()
|
||||
const measurementDialogOpen = ref(false)
|
||||
const openWarehouseSuggestionOnMeasurementLoad = ref(false)
|
||||
@@ -201,16 +200,16 @@ const feeConfirmRows = computed(() => {
|
||||
const summary = detail.value?.summary
|
||||
const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg)
|
||||
const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg)
|
||||
const measuredVolume = Number(measurementDetail.value?.actualVolumeCbm)
|
||||
const actualWeight = measuredGrossWeight > 0
|
||||
? measuredGrossWeight
|
||||
: measuredNetWeight > 0 ? measuredNetWeight : null
|
||||
const actualVolume = measuredVolume > 0 ? measuredVolume : null
|
||||
const actualTotals = measurementTotals(actualMeasurementRows.value)
|
||||
const actualWeight = Number(actualTotals.gross) > 0
|
||||
? actualTotals.gross
|
||||
: measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null
|
||||
const actualVolume = Number(actualTotals.volume) > 0 ? actualTotals.volume : null
|
||||
const customerTotals = measurementTotals(customerMeasurementRows.value)
|
||||
return [
|
||||
{ source: '客户下单数据', count: customerTotals.count, weight: customerTotals.gross, volume: customerTotals.volume ?? summary?.expectedVolumeCbm },
|
||||
{ source: '外箱规数据', count: measurementTotals(outerMeasurementRows.value).count, weight: measurementTotals(outerMeasurementRows.value).gross, volume: measurementTotals(outerMeasurementRows.value).volume },
|
||||
{ source: '实际测量数据', count: measurementDetail.value?.actualPackageCount, weight: actualWeight, volume: actualVolume },
|
||||
{ source: '实际测量数据', count: actualTotals.count, weight: actualWeight, volume: actualVolume },
|
||||
]
|
||||
})
|
||||
const smartLoadingOpen = ref(false)
|
||||
@@ -376,7 +375,10 @@ function calculatedMeasurementRow(
|
||||
const matchedDimensions = mode === 'ADD' && rule
|
||||
? rawDimensions.map((value) => value + rule.edgeAdditionCm * 2)
|
||||
: rawDimensions
|
||||
const chargeRule = activeMeasurementChargeRule.value
|
||||
const boundChargeRuleId = String(source.chargeRuleId || '')
|
||||
const chargeRule = measurementChargeRules.value
|
||||
.find((item) => item.id === boundChargeRuleId && item.status === 'ACTIVE')
|
||||
|| activeMeasurementChargeRule.value
|
||||
const specificationCoefficient = mode === 'MULTIPLY' && chargeRule
|
||||
? matchedSpecificationCoefficient(chargeRule, calculationSource)
|
||||
: 1
|
||||
@@ -461,19 +463,17 @@ async function selectAndSaveOuterMeasurementMode(mode: MeasurementCalculationMod
|
||||
if (!saved) measurementSectionModes.outer = previousMode
|
||||
}
|
||||
|
||||
async function handleOuterModeDropdownVisibleChange(visible: boolean) {
|
||||
if (visible) {
|
||||
outerModeAtDropdownOpen.value = measurementSectionModes.outer
|
||||
return
|
||||
}
|
||||
// Element Plus may emit dropdown-close before change. Wait until the change handler has
|
||||
// updated the mode/saving state, otherwise one click can submit both the old and new mode.
|
||||
await nextTick()
|
||||
const selectedSameMode = outerModeAtDropdownOpen.value === measurementSectionModes.outer
|
||||
outerModeAtDropdownOpen.value = null
|
||||
if (selectedSameMode && !measurementStrategySavingId.value) {
|
||||
void selectAndSaveOuterMeasurementMode(measurementSectionModes.outer)
|
||||
async function waitForSavedMeasurementVersion(tenantId: string, inboundId: string, savedVersion: number) {
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
try {
|
||||
const refreshed = await getInboundMeasurement(tenantId, inboundId)
|
||||
if (Number(refreshed.version) >= savedVersion) return refreshed
|
||||
} catch {
|
||||
// 保存结果已由 PUT 确认,短暂的详情读取失败只影响同步等待。
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
@@ -896,9 +896,6 @@ async function updateMeasurementCalculationMode(row: Record<string, unknown> | n
|
||||
const targetIds = new Set(measurement.items
|
||||
.filter((item) => !row || (itemId ? String(item.id) === itemId : normalizedIdentifier(item.itemNo) === itemNo))
|
||||
.map((item) => String(item.id)))
|
||||
const targetItemNos = new Set(measurement.items
|
||||
.filter((item) => targetIds.has(String(item.id)))
|
||||
.map((item) => normalizedIdentifier(item.itemNo)))
|
||||
if (!targetIds.size) {
|
||||
ElMessage.warning('当前数据尚未关联实测明细,暂不能设置计算方式')
|
||||
return false
|
||||
@@ -922,7 +919,7 @@ async function updateMeasurementCalculationMode(row: Record<string, unknown> | n
|
||||
ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则')
|
||||
return false
|
||||
}
|
||||
await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), {
|
||||
const savedDraft = await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), {
|
||||
measurementId: String(measurement.measurementId),
|
||||
version: measurement.version,
|
||||
actualGoodsName: measurement.actualGoodsName,
|
||||
@@ -952,25 +949,28 @@ async function updateMeasurementCalculationMode(row: Record<string, unknown> | n
|
||||
remark: item.remark,
|
||||
})),
|
||||
})
|
||||
measurementLoadedKey.value = ''
|
||||
await loadMeasurementDetails()
|
||||
const persistedItems = measurementDetail.value?.items
|
||||
.filter((item) => targetItemNos.has(normalizedIdentifier(item.itemNo))) || []
|
||||
const persisted = persistedItems.length === targetItemNos.size && persistedItems.every((item) => (
|
||||
item.coefficientMode === mode
|
||||
&& (mode !== 'MULTIPLY' || String(item.chargeRuleId) === String(activeRule!.id))
|
||||
&& [item.calculatedOuterLengthCm, item.calculatedOuterWidthCm, item.calculatedOuterHeightCm]
|
||||
.every((value, index, dimensions) => {
|
||||
// 旧服务实例可能尚未返回新版计算结果字段;计算方式已回读成功时不能误判并回滚下拉。
|
||||
if (dimensions.every((dimension) => dimension == null)) return true
|
||||
if (value == null) return false
|
||||
const expected = calculatedMeasurementRow(item as unknown as Record<string, unknown>, mode)
|
||||
return Math.abs(Number(value) - Number([expected.lengthCm, expected.widthCm, expected.heightCm][index])) < 0.001
|
||||
})
|
||||
))
|
||||
if (!persisted) {
|
||||
throw new Error('计算策略保存后回读不一致,请刷新后重试')
|
||||
// PUT 返回的新版本已经代表事务保存成功。详情查询可能经过读副本并短暂返回旧版本,
|
||||
// 不能用紧随其后的旧数据否定本次成功保存;先按新版本和本次规则更新本地视图。
|
||||
measurementDetail.value = {
|
||||
...measurement,
|
||||
version: savedDraft.version,
|
||||
measurementStatus: savedDraft.status,
|
||||
items: measurement.items.map((item) => targetIds.has(String(item.id)) ? {
|
||||
...item,
|
||||
coefficientMode: mode,
|
||||
chargeRuleId: mode === 'MULTIPLY' ? activeRule!.id : null,
|
||||
calculatedOuterLengthCm: null,
|
||||
calculatedOuterWidthCm: null,
|
||||
calculatedOuterHeightCm: null,
|
||||
} : item),
|
||||
}
|
||||
measurementSectionModes.outer = mode
|
||||
const refreshed = await waitForSavedMeasurementVersion(
|
||||
String(summary.tenantId), String(summary.inboundId), savedDraft.version,
|
||||
)
|
||||
if (refreshed) measurementDetail.value = refreshed
|
||||
measurementSectionModes.outer = mode
|
||||
measurementLoadedKey.value = ''
|
||||
ElMessage.success(`${row ? '当前数据' : '全部测量数据'}已保存并绑定计算规则,体积已重新计算`)
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -986,7 +986,9 @@ async function updateMeasurementCalculationMode(row: Record<string, unknown> | n
|
||||
async function saveWarehouseOuterDimensions(row: Record<string, unknown>) {
|
||||
const summary = detail.value?.summary
|
||||
const measurement = measurementDetail.value
|
||||
const editableMeasurementStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'])
|
||||
const editableMeasurementStatuses = new Set([
|
||||
'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT',
|
||||
])
|
||||
const currentStatus = editableMeasurementStatuses.has(measurement?.processStatus || '')
|
||||
? measurement!.processStatus
|
||||
: summary?.mainStatus || measurement?.processStatus || ''
|
||||
@@ -1430,15 +1432,10 @@ async function openFeeConfirmDialog() {
|
||||
const actualTotals = measurementTotals(actualMeasurementRows.value)
|
||||
const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg)
|
||||
const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg)
|
||||
const actualVolume = Number(measurementDetail.value?.actualVolumeCbm)
|
||||
feeConfirmWeight.value = measuredGrossWeight > 0
|
||||
? measuredGrossWeight
|
||||
: measuredNetWeight > 0
|
||||
? measuredNetWeight
|
||||
: Number(actualTotals.gross) > 0 ? Number(actualTotals.gross) : null
|
||||
feeConfirmVolume.value = actualVolume > 0
|
||||
? actualVolume
|
||||
: Number(actualTotals.volume) > 0 ? Number(actualTotals.volume) : null
|
||||
feeConfirmWeight.value = Number(actualTotals.gross) > 0
|
||||
? Number(actualTotals.gross)
|
||||
: measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null
|
||||
feeConfirmVolume.value = Number(actualTotals.volume) > 0 ? Number(actualTotals.volume) : null
|
||||
feeConfirmOpen.value = true
|
||||
}
|
||||
|
||||
@@ -2140,7 +2137,7 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
<div class="measurement-rule-selector">
|
||||
<span class="measurement-rule-selector__label">箱规计算规则选择</span>
|
||||
<el-select class="measurement-rule-selector__control" :model-value="measurementSectionModes.outer" aria-label="外箱规数据计算方式" :loading="Boolean(measurementStrategySavingId)" :disabled="Boolean(measurementStrategySavingId) || !can('platform:firstleg:inbound:measure:edit')" @change="selectAndSaveOuterMeasurementMode($event)" @visible-change="handleOuterModeDropdownVisibleChange($event)">
|
||||
<el-select class="measurement-rule-selector__control" :model-value="measurementSectionModes.outer" aria-label="外箱规数据计算方式" :loading="Boolean(measurementStrategySavingId)" :disabled="Boolean(measurementStrategySavingId) || !can('platform:firstleg:inbound:measure:edit')" @change="selectAndSaveOuterMeasurementMode($event)">
|
||||
<el-option label="实测原尺寸(不调整)" value="ACTUAL" />
|
||||
<el-option label="箱规尺寸" value="ADD" />
|
||||
<el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" />
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
- XLSX 批量导入使用 `BatchImportDialog.vue`;页面只提供模板下载、导入调用和成功后刷新,不复制文件校验、上传状态、错误报告或弹窗 CSS。
|
||||
- 模态框和抽屉优先使用 `AppDialog.vue`、`AppDrawer.vue`;但独立页面、弹框或抽屉的选择必须服从对应原型,不因已有组件方便而替换承载方式。
|
||||
- `AppDialog` 未提供自定义 `#header` 时保留 Element 原生 `title` 标题栏;`fullscreenable` 只在原标题栏追加全屏控件,不得仅为标题或全屏按钮再造一层 header。只有原型确有复合标题结构时才使用 `#header`。
|
||||
- 加载失败组合 `LoadErrorState.vue` 与 `ApiErrorMeta.vue`,保留重试和 `requestId`/`traceId`;状态与复制优先使用 `StatusBadge.vue`、`CopyButton.vue`。
|
||||
- 参考页面:查询和表格见 `../../modules/master-data/express-companies/pages/ExpressCompaniesView.vue`,批量导入见该页面与 `../../modules/master-data/counterparties/pages/PartnersView.vue`,抽屉见 `../../modules/downloads/pages/DownloadCenterView.vue`。
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import AppDialog from './AppDialog.vue'
|
||||
const ElDialogStub = defineComponent({
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
title: String,
|
||||
showClose: Boolean,
|
||||
closeOnClickModal: Boolean,
|
||||
closeOnPressEscape: Boolean,
|
||||
fullscreen: Boolean,
|
||||
},
|
||||
template: '<section data-testid="dialog"><slot name="header" /><slot /><slot name="footer" /></section>',
|
||||
template: '<section data-testid="dialog"><h2 v-if="!$slots.header" data-testid="native-title">{{ title }}</h2><slot name="header" /><slot /><slot name="footer" /></section>',
|
||||
})
|
||||
|
||||
const ElButtonStub = defineComponent({
|
||||
@@ -73,6 +74,9 @@ describe('AppDialog', () => {
|
||||
|
||||
const wrapper = mountDialog({ fullscreenable: true, defaultFullscreen: true })
|
||||
expect(wrapper.getComponent(ElDialogStub).props('fullscreen')).toBe(true)
|
||||
expect(wrapper.get('[data-testid="native-title"]').text()).toBe('编辑资料')
|
||||
expect(wrapper.find('.app-dialog__header').exists()).toBe(false)
|
||||
expect(wrapper.find('.app-dialog__native-fullscreen-control').exists()).toBe(true)
|
||||
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
|
||||
@@ -83,9 +87,18 @@ describe('AppDialog', () => {
|
||||
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')
|
||||
|
||||
expect(source).toContain(':global(.app-dialog.el-dialog) { display: flex;')
|
||||
expect(source).toContain(':global(.app-dialog.el-dialog) { position: relative; 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('max-height: calc(100svh - 20px)')
|
||||
})
|
||||
|
||||
it('keeps the native title bar when fullscreen is enabled without a custom header', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDialog.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain('<template v-if="$slots.header" #header>')
|
||||
expect(source).not.toContain('<template v-if="$slots.header || fullscreenable" #header>')
|
||||
expect(source).toContain("fullscreenable && !$slots.header")
|
||||
expect(source).toContain('app-dialog__native-fullscreen-control')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,22 +80,33 @@ function closeDialog() {
|
||||
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
|
||||
:destroy-on-close="destroyOnClose"
|
||||
:fullscreen="fullscreenable && fullscreen"
|
||||
:class="{ 'has-native-fullscreen-control': fullscreenable && !$slots.header }"
|
||||
>
|
||||
<template v-if="$slots.header || fullscreenable" #header>
|
||||
<div v-if="fullscreenable" class="app-dialog__header">
|
||||
<template v-if="$slots.header" #header>
|
||||
<div 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>
|
||||
<slot name="header" :fullscreen="fullscreen" :toggle-fullscreen="toggleFullscreen" />
|
||||
</div>
|
||||
<el-tooltip :content="fullscreen ? '退出全屏' : '展开全屏'" placement="bottom">
|
||||
<el-tooltip v-if="fullscreenable" :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>
|
||||
<el-tooltip v-if="fullscreenable && !$slots.header" :content="fullscreen ? '退出全屏' : '展开全屏'" placement="bottom">
|
||||
<el-button
|
||||
class="app-dialog__native-fullscreen-control"
|
||||
:class="{ 'is-without-close': !showClose || submittingLocked }"
|
||||
text
|
||||
:aria-label="fullscreen ? '退出全屏' : '展开全屏'"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<Minimize2 v-if="fullscreen" :size="18" />
|
||||
<Maximize2 v-else :size="18" />
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -117,13 +128,16 @@ function closeDialog() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.app-dialog.el-dialog) { display: flex; max-height: min(90vh, 900px); flex-direction: column; overflow: hidden; }
|
||||
:global(.app-dialog.el-dialog) { position: relative; 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__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
:global(.app-dialog.has-native-fullscreen-control .el-dialog__title) { display: block; padding-right: 44px; }
|
||||
.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__native-fullscreen-control { position: absolute; z-index: 2; top: 10px; right: 48px; color: var(--cc-muted); }
|
||||
.app-dialog__native-fullscreen-control.is-without-close { right: 12px; }
|
||||
.app-dialog__body { min-height: 0; }
|
||||
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
|
||||
|
||||
Reference in New Issue
Block a user