Merge remote-tracking branch 'origin/release/test' into dev

# Conflicts:
#	AGENTS.md
This commit is contained in:
2026-09-03 11:02:21 +08:00
30 changed files with 1558 additions and 88 deletions
+1 -1
View File
@@ -47,7 +47,7 @@
| 订单列表、详情、商品图片与入库费用 | `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/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md` | `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/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
@@ -0,0 +1,30 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import SmartLoadingDialog from './SmartLoadingDialog.vue'
describe('SmartLoadingDialog', () => {
afterEach(() => { document.body.innerHTML = '' })
it('expands to the viewport and can return to the normal dialog size', async () => {
const wrapper = mount(SmartLoadingDialog, {
attachTo: document.body,
props: { modelValue: true },
global: {
plugins: [ElementPlus],
stubs: { GlobalSmartLoadingView: { template: '<div>智能装柜工作台</div>' } },
},
})
await flushPromises()
expect(wrapper.findAll('.smart-loading-dialog__warehouses [role="tab"]').map((tab) => tab.text())).toEqual(['义乌仓', '东莞仓'])
const expand = wrapper.get('button[aria-label="展开全屏"]')
await expand.trigger('click')
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull()
expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true)
})
})
@@ -1,8 +1,25 @@
<script setup lang="ts">
import { Maximize2, Minimize2 } from '@lucide/vue'
import { ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
import { SMART_LOADING_WAREHOUSE_GROUPS } from '../smartLoadingChannelGroups'
const open = defineModel<boolean>({ default: false })
const fullscreen = ref(false)
const warehouseCode = ref('')
const workbench = ref<InstanceType<typeof GlobalSmartLoadingView> | null>(null)
watch(open, (value) => {
if (!value) {
fullscreen.value = false
warehouseCode.value = ''
}
})
function selectWarehouse(code: string) {
void workbench.value?.selectWarehouse(code)
}
</script>
<template>
@@ -11,18 +28,44 @@ const open = defineModel<boolean>({ default: false })
class="smart-loading-dialog"
title="智能装柜"
width="min(1600px, 98vw)"
:fullscreen="fullscreen"
cancel-text="关闭"
:show-confirm="false"
destroy-on-close
>
<GlobalSmartLoadingView embedded />
<template #header>
<div class="smart-loading-dialog__header">
<div class="smart-loading-dialog__heading">
<span>智能装柜</span>
<el-tabs :model-value="warehouseCode" class="smart-loading-dialog__warehouses" aria-label="装柜仓库" @tab-change="selectWarehouse(String($event))">
<el-tab-pane v-for="warehouse in SMART_LOADING_WAREHOUSE_GROUPS" :key="warehouse.code" :label="warehouse.name" :name="warehouse.code" />
</el-tabs>
</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 ref="workbench" v-model:warehouse-code="warehouseCode" embedded />
</AppDialog>
</template>
<style scoped>
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw); 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) { padding: 0 16px 16px; }
:global(.smart-loading-dialog .app-dialog__body) { min-width: 0; }
.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__warehouses { min-width: 0; }
.smart-loading-dialog__warehouses :deep(.el-tabs__header) { margin: 0; }
.smart-loading-dialog__warehouses :deep(.el-tabs__nav-wrap::after), .smart-loading-dialog__warehouses :deep(.el-tabs__content) { display: none; }
.smart-loading-dialog__warehouses :deep(.el-tabs__item) { height: 38px; padding-inline: 16px; font-size: 14px; font-weight: 600; }
.smart-loading-dialog__header :deep(.el-button) { flex: 0 0 auto; margin-right: 24px; color: var(--cc-muted); }
@media (max-width: 720px) {
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: calc(100vw - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
:global(.smart-loading-dialog .el-dialog__body) { padding: 0 8px 8px; }
@@ -87,7 +87,9 @@ describe('GlobalSmartLoadingView', () => {
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
expect(wrapper.text()).toContain('候选装柜明细')
expect(wrapper.text()).toContain('美森正班海卡包税')
expect(wrapper.text()).toContain('美森正班')
expect(wrapper.findAll('.channel-tabs [role="tab"]')).toHaveLength(9)
expect(wrapper.text()).not.toContain('美森正班海卡包税')
expect(wrapper.text()).toContain('另一企业')
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
expect(wrapper.text()).not.toContain('LP-99')
@@ -113,7 +115,8 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('纽约EXX海铁')
expect(wrapper.text()).toContain('EXX')
expect(wrapper.text()).not.toContain('纽约EXX海铁')
expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
})
@@ -157,7 +160,7 @@ describe('GlobalSmartLoadingView', () => {
expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待补录')
expect(option.text()).not.toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('渠道:美森正班')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HC')
expect(option.text()).not.toContain('2088475410134773762')
@@ -184,7 +187,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).not.toContain('MSCU-2')
expect(wrapper.text()).not.toContain('个柜次')
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('合德海卡包税'))
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '合德')
expect(tab).toBeDefined()
await tab!.trigger('click')
await flushPromises()
@@ -195,6 +198,78 @@ describe('GlobalSmartLoadingView', () => {
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId)
})
it('merges detailed pricing channels into the same confirmed display group', async () => {
const parcelContainer = {
...container,
id: '9007199254740998',
planId: '9007199254740999',
cabinetNo: 'CB-2',
containerNo: 'MSCU-2',
channelCode: 'MATSON_CLX_PARCEL',
channelName: '美森正班海派',
}
vi.mocked(listContainers).mockResolvedValue({ records: [container, parcelContainer], page: 1, size: 100, total: 2, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('.container-option')).toHaveLength(2)
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).toContain('MSCU-2')
expect(wrapper.text()).not.toContain('美森正班海卡包税')
expect(wrapper.text()).not.toContain('美森正班海派')
})
it('isolates Yiwu and Dongguan containers when the host changes the warehouse', async () => {
const dongguanContainer = {
...container,
id: '9007199254740998',
planId: '9007199254740999',
cabinetNo: 'CB-DG',
containerNo: 'MSCU-DG',
warehouseId: '18',
warehouseName: '东莞仓',
}
vi.mocked(listContainers).mockResolvedValue({ records: [container, dongguanContainer], page: 1, size: 100, total: 2, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId }))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).not.toContain('MSCU-DG')
await (wrapper.vm as unknown as { selectWarehouse: (code: string) => Promise<void> }).selectWarehouse('DONGGUAN')
await flushPromises()
expect(wrapper.text()).toContain('MSCU-DG')
expect(wrapper.text()).not.toContain('MSCU-1')
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(dongguanContainer.id, dongguanContainer.warehouseId)
})
it('keeps the selected channel when the warehouse changes even if that warehouse has no matching container', async () => {
const dongguanOvertimeContainer = {
...container,
id: '9007199254740998',
planId: '9007199254740999',
cabinetNo: 'CB-DG-MAX',
containerNo: 'MSCU-DG-MAX',
warehouseId: '18',
warehouseName: '东莞仓',
channelCode: 'MATSON_MAX_TRUCK',
channelName: '美森加班海卡',
}
vi.mocked(listContainers).mockResolvedValue({ records: [container, dongguanOvertimeContainer], page: 1, size: 100, total: 2, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await (wrapper.vm as unknown as { selectWarehouse: (code: string) => Promise<void> }).selectWarehouse('DONGGUAN')
await flushPromises()
expect(wrapper.get('.channel-tabs [role="tab"][aria-selected="true"]').text()).toBe('美森正班')
expect(wrapper.findAll('.container-option')).toHaveLength(0)
expect(wrapper.text()).not.toContain('MSCU-DG-MAX')
expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(1)
})
it('filters candidates by an active name-only customer selector and accountId', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
@@ -30,8 +30,15 @@ import type {
} from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
import {
SMART_LOADING_CHANNEL_GROUPS,
SMART_LOADING_WAREHOUSE_GROUPS,
resolveSmartLoadingChannelGroup,
resolveSmartLoadingWarehouseGroup,
} from '../smartLoadingChannelGroups'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const activeWarehouseCode = defineModel<string>('warehouseCode', { default: '' })
const access = useAccessPreviewStore()
const router = useRouter()
@@ -66,17 +73,19 @@ const selectedContainer = computed(() => containers.value.find((item) => item.id
const orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const channelTabs = computed(() => {
const channels = new Map<string, { code: string; name: string }>()
for (const item of orchestratableContainers.value) {
if (!item.channelCode) continue
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
code: item.channelCode,
name: item.channelName || item.channelCode,
})
}
return [...channels.values()]
})
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
)).length)
const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
resolveSmartLoadingWarehouseGroup(item.warehouseName)
&& resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
)).length)
const unmappedWarehouseContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
!resolveSmartLoadingWarehouseGroup(item.warehouseName)
)).length)
const warehouseTabs = computed(() => SMART_LOADING_WAREHOUSE_GROUPS)
const channelTabs = computed(() => SMART_LOADING_CHANNEL_GROUPS)
const activeWarehouse = computed(() => warehouseTabs.value.find((item) => item.code === activeWarehouseCode.value) || null)
const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null)
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
@@ -194,8 +203,9 @@ const queryFields = computed<QueryField[]>(() => [
const filteredContainers = computed(() => containers.value.filter((item) => (
['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)
&& resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === activeWarehouseCode.value
&& Boolean(activeChannelCode.value)
&& item.channelCode === activeChannelCode.value
&& resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === activeChannelCode.value
)))
const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '')
@@ -204,7 +214,7 @@ const filteredCandidates = computed(() => {
const arrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange)
? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10))
: []
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
const channelCode = selectedContainer.value?.channelCode
return displayedCandidates.value.filter((item) => {
if (!channelCode || item.channelCode !== channelCode) return false
if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false
@@ -287,6 +297,13 @@ function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> =
if (syncContainerStatus) container.planStatus = next.status
}
}
function preferredChannelCode(warehouseCode: string) {
return channelTabs.value.find((group) => orchestratableContainers.value.some((item) => (
resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === warehouseCode
&& resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === group.code
)))?.code || channelTabs.value[0]?.code || ''
}
function clearSelectionData() {
plan.value = null
candidates.value = []
@@ -346,8 +363,13 @@ async function loadAll(preserved: Record<string, number> = {}) {
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
if (!warehouseTabs.value.some((item) => item.code === activeWarehouseCode.value)) {
activeWarehouseCode.value = warehouseTabs.value.find((group) => orchestratableContainers.value.some((item) => (
resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === group.code
)))?.code || warehouseTabs.value[0]?.code || ''
}
if (!channelTabs.value.some((item) => item.code === activeChannelCode.value)) {
activeChannelCode.value = channelTabs.value[0]?.code || ''
activeChannelCode.value = preferredChannelCode(activeWarehouseCode.value)
}
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
await loadSelectedData(preserved)
@@ -374,6 +396,13 @@ async function selectContainer(containerId: string) {
selectedContainerId.value = containerId
await loadSelectedData()
}
async function selectWarehouse(warehouseCode: string) {
if (activeWarehouseCode.value === warehouseCode) return
activeWarehouseCode.value = warehouseCode
selectedContainerId.value = preferredContainerId()
await loadSelectedData()
}
defineExpose({ selectWarehouse })
async function selectChannel(channelCode: string) {
if (activeChannelCode.value === channelCode) return
activeChannelCode.value = channelCode
@@ -558,10 +587,13 @@ onMounted(() => { void load(); void loadCustomers() })
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
</dl>
</div>
<el-alert v-else-if="missingChannelContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-else-if="completedContainerCount" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
<el-alert v-if="missingChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-if="unmappedChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedChannelContainerCount} 个可编排柜次的渠道未纳入固定 9 类,已隔离`" />
<el-alert v-if="unmappedWarehouseContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedWarehouseContainerCount} 个可编排柜次不属于义乌仓或东莞仓,已隔离`" />
<el-alert v-if="completedContainerCount && !orchestratableContainers.length" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
<QueryToolbar
v-model="query"
class="smart-loading-query"
ariaLabel="智能装柜筛选"
:fields="queryFields"
preference-key="global-smart-loading-query-v2"
@@ -578,7 +610,7 @@ onMounted(() => { void load(); void loadCustomers() })
</LoadErrorState>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeWarehouse?.name || '未选择仓库' }} · {{ activeChannel?.name || '未选择渠道' }}</small></div></header>
<DataTable
v-model:page="candidatePage"
v-model:page-size="candidatePageSize"
@@ -629,7 +661,7 @@ onMounted(() => { void load(); void loadCustomers() })
</span>
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>渠道:{{ resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.name || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
</button>
@@ -707,9 +739,9 @@ onMounted(() => { void load(); void loadCustomers() })
</template>
<style scoped>
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
.global-loading-page { display: grid; gap: 12px; min-width: 0; }
.embedded-smart-loading { min-width: 0; }
.channel-summary-row { display: grid; grid-template-columns: minmax(240px, 1fr) auto; min-width: 0; align-items: end; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-summary-row { display: grid; grid-template-columns: minmax(360px, 1fr) auto; min-width: 0; align-items: end; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tabs { min-width: 0; }
.channel-tabs :deep(.el-tabs__header) { margin: 0 0 -2px; }
.channel-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
@@ -720,6 +752,7 @@ onMounted(() => { void load(); void loadCustomers() })
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
:deep(.smart-loading-query.query-toolbar) { padding: 0; border: 0; background: transparent; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
@@ -764,6 +797,7 @@ onMounted(() => { void load(); void loadCustomers() })
}
@media (max-width: 720px) {
.channel-summary-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
.compact-summary { grid-column: 1 / -1; }
.compact-summary { min-height: 0; justify-content: flex-start; flex-wrap: wrap; gap: 6px 14px; padding: 8px 12px; white-space: normal; }
.capacity-grid { grid-template-columns: 1fr; }
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
SMART_LOADING_CHANNEL_GROUPS,
SMART_LOADING_WAREHOUSE_GROUPS,
resolveSmartLoadingChannelGroup,
resolveSmartLoadingWarehouseGroup,
} from './smartLoadingChannelGroups'
describe('smartLoadingChannelGroups', () => {
it('exposes the nine confirmed display groups in a fixed order', () => {
expect(SMART_LOADING_CHANNEL_GROUPS.map((group) => group.name)).toEqual([
'美森正班', '美森加班', 'EXX', '合德', '洛杉矶普船', '芝加哥普船', '休斯顿普船', '萨凡纳普船', '纽约普船海卡',
])
})
it.each([
['MATSON_CLX_TRUCK', '美森正班海卡', '美森正班'],
['MATSON_CLX_PARCEL', '美森正班海派', '美森正班'],
['BYHP', '美森加班海派', '美森加班'],
['EXX_RAIL_NY', '纽约EXX海铁', 'EXX'],
['HDHK', '合德海卡包税', '合德'],
['OA_LA_FULL_TRUCK', '洛杉矶普船全卡', '洛杉矶普船'],
['OA_PARCEL', '普船海派', '洛杉矶普船'],
['CPHT', '芝加哥普船海铁', '芝加哥普船'],
['HPHK', '休斯顿普船海卡', '休斯顿普船'],
['SPHK', '萨凡纳普船海卡', '萨凡纳普船'],
['NPHK', '纽约普船海卡', '纽约普船海卡'],
])('maps %s / %s into %s', (channelCode, channelName, expected) => {
expect(resolveSmartLoadingChannelGroup(channelCode, channelName)?.name).toBe(expected)
})
it('does not leak an unconfirmed channel into the fixed display groups', () => {
expect(resolveSmartLoadingChannelGroup('UNKNOWN', '未确认新渠道')).toBeNull()
})
it('exposes Yiwu and Dongguan as the fixed top-level warehouses', () => {
expect(SMART_LOADING_WAREHOUSE_GROUPS.map((group) => group.name)).toEqual(['义乌仓', '东莞仓'])
expect(resolveSmartLoadingWarehouseGroup('义乌国内仓')?.code).toBe('YIWU')
expect(resolveSmartLoadingWarehouseGroup('东莞仓')?.code).toBe('DONGGUAN')
expect(resolveSmartLoadingWarehouseGroup('其他仓')).toBeNull()
})
})
@@ -0,0 +1,91 @@
export type SmartLoadingChannelGroup = {
code: string
name: string
channelCodes: readonly string[]
}
export type SmartLoadingWarehouseGroup = {
code: string
name: string
nameKeyword: string
}
export const SMART_LOADING_WAREHOUSE_GROUPS: readonly SmartLoadingWarehouseGroup[] = [
{ code: 'YIWU', name: '义乌仓', nameKeyword: '义乌' },
{ code: 'DONGGUAN', name: '东莞仓', nameKeyword: '东莞' },
] as const
export const SMART_LOADING_CHANNEL_GROUPS: readonly SmartLoadingChannelGroup[] = [
{
code: 'MATSON_CLX',
name: '美森正班',
channelCodes: ['MATSON_CLX_TRUCK', 'MATSON_CLX_PARCEL', 'ZBYHK', 'ZBYHWK', 'ZBYHP'],
},
{
code: 'MATSON_MAX',
name: '美森加班',
channelCodes: ['MATSON_MAX_TRUCK', 'MATSON_MAX_PARCEL', 'BYHK', 'BYHWK', 'BYHP'],
},
{
code: 'EXX',
name: 'EXX',
channelCodes: ['EXX_TRUCK', 'EXX_RAIL_NY', 'EXXHK', 'EXXHWK', 'NYEXX'],
},
{
code: 'HEDE',
name: '合德',
channelCodes: ['HEDE_TRUCK', 'HEDE_PARCEL', 'HDHK', 'HDHWK', 'HDHP'],
},
{
code: 'OA_LA',
name: '洛杉矶普船',
channelCodes: ['OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_PARCEL', 'PCHK', 'PCHWK', 'PCKK', 'PCHP'],
},
{
code: 'OA_CHICAGO',
name: '芝加哥普船',
channelCodes: ['OA_CHICAGO_RAIL', 'CPHT'],
},
{
code: 'OA_HOUSTON',
name: '休斯顿普船',
channelCodes: ['OA_HOUSTON_TRUCK', 'HPHK'],
},
{
code: 'OA_SAVANNAH',
name: '萨凡纳普船',
channelCodes: ['OA_SAVANNAH_TRUCK', 'SPHK'],
},
{
code: 'OA_NEW_YORK_TRUCK',
name: '纽约普船海卡',
channelCodes: ['OA_NEW_YORK_TRUCK', 'NPHK', 'NPHWK'],
},
] as const
export function resolveSmartLoadingChannelGroup(channelCode?: string | null, channelName?: string | null) {
const normalizedCode = String(channelCode || '').trim().toUpperCase()
if (normalizedCode) {
const codeMatch = SMART_LOADING_CHANNEL_GROUPS.find((group) => group.channelCodes.includes(normalizedCode))
if (codeMatch) return codeMatch
}
const normalizedName = String(channelName || '').replace(/\s+/g, '')
if (!normalizedName) return null
if (normalizedName.includes('美森正班')) return SMART_LOADING_CHANNEL_GROUPS[0]
if (normalizedName.includes('美森加班')) return SMART_LOADING_CHANNEL_GROUPS[1]
if (normalizedName.toUpperCase().includes('EXX')) return SMART_LOADING_CHANNEL_GROUPS[2]
if (normalizedName.includes('合德')) return SMART_LOADING_CHANNEL_GROUPS[3]
if (normalizedName.includes('洛杉矶普船') || normalizedName === '普船海派') return SMART_LOADING_CHANNEL_GROUPS[4]
if (normalizedName.includes('芝加哥普船')) return SMART_LOADING_CHANNEL_GROUPS[5]
if (normalizedName.includes('休斯顿普船')) return SMART_LOADING_CHANNEL_GROUPS[6]
if (normalizedName.includes('萨凡纳普船')) return SMART_LOADING_CHANNEL_GROUPS[7]
if (normalizedName.includes('纽约普船')) return SMART_LOADING_CHANNEL_GROUPS[8]
return null
}
export function resolveSmartLoadingWarehouseGroup(warehouseName?: string | null) {
const normalizedName = String(warehouseName || '').replace(/\s+/g, '')
if (!normalizedName) return null
return SMART_LOADING_WAREHOUSE_GROUPS.find((group) => normalizedName.includes(group.nameKeyword)) || null
}
@@ -2,12 +2,24 @@ import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getCustomerDimensionConfig, updateCustomerDimensionConfig, type CustomerDimensionConfig } from '@/modules/dimension-config'
import { getCustomerDimensionConfig, updateCustomerDimensionConfig, updateCustomerDimensionDefaultCalculationMode, type CustomerDimensionConfig } from '@/modules/dimension-config'
import {
createCustomerChargeRule,
listCustomerChargeRules,
setCustomerChargeRuleDefault,
type CustomerChargeRule,
} from '@/modules/fba-pricing'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn() }))
vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn(), updateCustomerDimensionDefaultCalculationMode: vi.fn() }))
vi.mock('@/modules/fba-pricing', () => ({
createCustomerChargeRule: vi.fn(),
listCustomerChargeRules: vi.fn(),
retireCustomerChargeRule: vi.fn(),
setCustomerChargeRuleDefault: vi.fn(),
}))
const boxes = [
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
@@ -17,6 +29,7 @@ const boxes = [
] as const
const config: CustomerDimensionConfig = {
tenantId: '8',
defaultCalculationMode: 'ADD',
rules: boxes.map(([boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, remark], index) => ({
id: String(100 + index),
boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm: 2,
@@ -25,6 +38,13 @@ const config: CustomerDimensionConfig = {
remark, version: 3, createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:20:00',
})),
}
const chargeRule: CustomerChargeRule = {
id: '201', tenantId: '8', ruleCode: 'RULE-201', ruleVersion: 1, customerId: '8', customerName: '柏悦物流',
businessType: 'FBA', chargeMode: 'CBM', taxMode: 'TAX_INCLUDED', conversionFactor: 167,
lengthFactor: 1, widthFactor: 1, heightFactor: 1, defaultRule: false, roundingScale: 2,
roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom: '2026-09-02T10:00:00', effectiveTo: null,
status: 'ACTIVE', remark: null,
}
enableAutoUnmount(afterEach)
@@ -33,10 +53,16 @@ describe('CustomerBillingRulesPanel', () => {
vi.clearAllMocks()
document.body.innerHTML = ''
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['firstleg:dimension-config:view', 'firstleg:dimension-config:edit']
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = [
'firstleg:dimension-config:view', 'firstleg:dimension-config:edit',
'firstleg:fba-pricing:rule:view', 'firstleg:fba-pricing:rule:edit',
]
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(getCustomerDimensionConfig).mockResolvedValue(config)
vi.mocked(updateCustomerDimensionConfig).mockResolvedValue(config)
vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue(config)
vi.mocked(listCustomerChargeRules).mockResolvedValue([chargeRule])
vi.mocked(createCustomerChargeRule).mockResolvedValue(chargeRule)
})
it('renders the four fixed carton dimension rows and calculated values', async () => {
@@ -90,4 +116,33 @@ describe('CustomerBillingRulesPanel', () => {
await flushPromises()
expect(document.body.textContent).toContain('编辑箱规尺寸')
})
it('saves the coefficient set and can use it as the customer default calculation mode', async () => {
vi.mocked(listCustomerChargeRules)
.mockResolvedValueOnce([chargeRule])
.mockResolvedValueOnce([{ ...chargeRule, defaultRule: true }])
vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue({
...config,
defaultCalculationMode: 'MULTIPLY',
})
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click()
await flushPromises()
expect(document.body.textContent).toContain('箱规按权重系数计算')
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '保存系数规则')?.click()
await flushPromises()
expect(createCustomerChargeRule).toHaveBeenCalledWith('8', expect.objectContaining({
defaultRule: false,
remark: expect.stringMatching(/SMALL:30,20,20;系数:\d+(?:\.\d+)?/),
}))
expect(setCustomerChargeRuleDefault).toHaveBeenCalledWith('8', '201')
const defaultRuleCheckboxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
defaultRuleCheckboxes[1]?.click()
await flushPromises()
expect(updateCustomerDimensionDefaultCalculationMode).toHaveBeenCalledWith('8', 'MULTIPLY')
})
})
@@ -7,6 +7,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
import {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
updateCustomerDimensionDefaultCalculationMode,
type CustomerDimensionBoxType,
type CustomerDimensionConfig,
type CustomerDimensionConfigFormModel,
@@ -16,19 +17,51 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import { createCustomerChargeRule, listCustomerChargeRules, setCustomerChargeRuleDefault, type CustomerChargeRule, type CreateCustomerChargeRuleRequest } from '@/modules/fba-pricing'
const props = defineProps<{ customerId: string; customerName: string; customerCode?: string }>()
const access = useAccessPreviewStore()
const canView = computed(() => access.can('firstleg:dimension-config:view'))
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
const canChargeView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
const canChargeEdit = computed(() => canChargeView.value && access.can('firstleg:fba-pricing:rule:edit'))
const config = ref<CustomerDimensionConfig | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const editorVisible = ref(false)
const editorSaving = ref(false)
const editorDirty = ref(false)
const defaultCalculationMode = ref<'DIMENSION' | 'WEIGHT'>('DIMENSION')
const defaultModeSaving = ref(false)
const editorForm = ref<FormInstance>()
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
const chargeRules = ref<CustomerChargeRule[]>([])
const visibleChargeRules = computed(() => chargeRules.value.filter((rule) => rule.status === 'ACTIVE'))
const chargeDisplayRows = computed(() => {
const rule = visibleChargeRules.value.find((item) => item.defaultRule) || visibleChargeRules.value[0]
if (!rule) return []
return BOX_ORDER.map((boxType) => ({
...rule,
boxType,
boxTypeName: BOX_NAMES[boxType],
dimension: chargeDimension(boxType),
coefficient: Number(chargeRows.find((item) => item.boxType === boxType)?.coefficient || rule.lengthFactor || 1),
weight: chargeRows.find((item) => item.boxType === boxType)?.weight || 0,
remark: chargeRows.find((item) => item.boxType === boxType)?.remark || '',
}))
})
const chargeLoading = ref(false)
const chargeSaving = ref(false)
const chargeForm = reactive<CreateCustomerChargeRuleRequest>({
ruleCode: '', customerId: props.customerId, customerName: props.customerName, businessType: 'FBA', chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167,
lengthFactor: 1, widthFactor: 1, heightFactor: 1, defaultRule: false, roundingScale: 2, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom: new Date().toISOString().slice(0, 19), effectiveTo: null, remark: null,
})
const chargeRows = reactive([
{ boxType: 'SMALL' as CustomerDimensionBoxType, length: 30, width: 20, height: 20, coefficient: 1.1, weight: 5, coefficientType: '体积系数', remark: '标准小箱' },
{ boxType: 'MEDIUM' as CustomerDimensionBoxType, length: 40, width: 30, height: 30, coefficient: 1.1, weight: 8, coefficientType: '体积系数', remark: '标准中箱' },
{ boxType: 'LARGE' as CustomerDimensionBoxType, length: 50, width: 40, height: 40, coefficient: 1.1, weight: 12, coefficientType: '体积系数', remark: '标准大箱' },
{ boxType: 'EXTRA_LARGE' as CustomerDimensionBoxType, length: 60, width: 50, height: 50, coefficient: 1.1, weight: 15, coefficientType: '体积系数', remark: '特大箱' },
])
const BOX_ORDER: readonly CustomerDimensionBoxType[] = ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']
const BOX_NAMES: Record<CustomerDimensionBoxType, string> = {
@@ -58,6 +91,7 @@ watch(form, () => {
watch(() => props.customerId, () => {
editorVisible.value = false
defaultCalculationMode.value = 'DIMENSION'
void loadConfig()
})
@@ -73,6 +107,7 @@ async function loadConfig() {
try {
config.value = await getCustomerDimensionConfig(props.customerId)
config.value.rules = sortRules(config.value.rules)
defaultCalculationMode.value = config.value.defaultCalculationMode === 'MULTIPLY' ? 'WEIGHT' : 'DIMENSION'
} catch (error) {
config.value = null
loadError.value = getApiErrorDetails(error, '箱规尺寸规则加载失败,请稍后重试')
@@ -87,6 +122,7 @@ function cloneRules(rules: CustomerDimensionRule[]) {
async function openEditor() {
if (!config.value) return
Object.assign(chargeForm, { ruleCode: `RULE-${Date.now()}`, customerId: props.customerId, customerName: props.customerName, businessType: 'FBA', chargeMode: 'CBM', taxMode: 'TAX_INCLUDED', conversionFactor: 167, lengthFactor: 1, widthFactor: 1, heightFactor: 1, defaultRule: false, roundingScale: 2, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom: new Date().toISOString().slice(0, 19), effectiveTo: null, remark: null })
form.rules = cloneRules(config.value.rules)
await nextTick()
editorDirty.value = false
@@ -157,8 +193,8 @@ async function saveConfig() {
}))
config.value = await updateCustomerDimensionConfig(props.customerId, { rules })
config.value.rules = sortRules(config.value.rules)
form.rules = cloneRules(config.value.rules)
editorDirty.value = false
editorVisible.value = false
ElMessage.success('箱规尺寸规则已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '箱规尺寸规则保存失败,请刷新后重试').message)
@@ -171,11 +207,80 @@ function formatDimension(...values: number[]) {
return values.map((value) => Number(value.toFixed(2)).toString()).join(' × ')
}
async function selectDefaultCalculationMode(mode: 'DIMENSION' | 'WEIGHT') {
if (!canEdit.value || defaultModeSaving.value || mode === defaultCalculationMode.value) return
if (mode === 'WEIGHT' && !visibleChargeRules.value.some((rule) => rule.defaultRule)) {
ElMessage.warning('请先保存并启用一套权重系数配置,再将其设为默认规则')
return
}
const previous = defaultCalculationMode.value
defaultCalculationMode.value = mode
defaultModeSaving.value = true
try {
config.value = await updateCustomerDimensionDefaultCalculationMode(
props.customerId,
mode === 'WEIGHT' ? 'MULTIPLY' : 'ADD',
)
config.value.rules = sortRules(config.value.rules)
ElMessage.success('默认计算规则已更新')
} catch (error) {
defaultCalculationMode.value = previous
ElMessage.error(getApiErrorDetails(error, '默认计算规则保存失败').message)
} finally {
defaultModeSaving.value = false
}
}
async function saveChargeConfig() {
const coefficient = Number(chargeRows[0].coefficient)
if (!canChargeEdit.value || chargeRows.some((row) => !(Number(row.coefficient) > 0))) { ElMessage.warning('权重系数必须大于 0'); return }
chargeSaving.value = true
try {
// A saved version becomes the active coefficient set immediately.
const created = await createCustomerChargeRule(props.customerId, { ...chargeForm, ruleCode: `RULE-${Date.now()}`, lengthFactor: coefficient, widthFactor: coefficient, heightFactor: coefficient, defaultRule: false, remark: chargeRows.map((row) => `${row.boxType}:${row.length},${row.width},${row.height};系数:${row.coefficient};${BOX_NAMES[row.boxType]}:${row.remark || '—'}`).join(';') })
await setCustomerChargeRuleDefault(props.customerId, created.id)
await loadChargeRules()
ElMessage.success('权重系数规则已保存')
} catch (error) { ElMessage.error(getApiErrorDetails(error, '权重系数规则保存失败').message) }
finally { chargeSaving.value = false }
}
function dimensionRule(boxType: CustomerDimensionBoxType) { return config.value?.rules.find((rule) => rule.boxType === boxType) }
function chargeDimension(boxType: CustomerDimensionBoxType) {
const row = chargeRows.find((item) => item.boxType === boxType)
if (!row) return null
return { innerLengthCm: row.length, innerWidthCm: row.width, innerHeightCm: row.height, volumeCbm: (row.length * row.width * row.height) / 1_000_000 }
}
function dimensionText(boxType: CustomerDimensionBoxType) { const rule = dimensionRule(boxType); return rule ? formatDimension(rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm) : '—' }
function volumeText(boxType: CustomerDimensionBoxType) { const rule = dimensionRule(boxType); return rule ? Number(rule.volumeCbm).toFixed(3) : '—' }
function weightedVolumeText(boxType: CustomerDimensionBoxType, coefficient: number) { const rule = dimensionRule(boxType); return rule ? (Number(rule.volumeCbm) * Number(coefficient || 1)).toFixed(3) : '—' }
function weightText(boxType: CustomerDimensionBoxType) { return chargeRows.find((row) => row.boxType === boxType)?.weight.toFixed(2) || '—' }
function formatDate(value: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '暂无记录'
}
onMounted(loadConfig)
async function loadChargeRules() {
if (!canChargeView.value) return
chargeLoading.value = true
try {
chargeRules.value = await listCustomerChargeRules(props.customerId)
const remark = chargeRules.value.find((item) => item.defaultRule)?.remark || ''
const fallbackCoefficient = Number(chargeRules.value.find((item) => item.defaultRule)?.lengthFactor || 1)
chargeRows.forEach((row) => {
const match = remark.match(new RegExp(`${row.boxType}:(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?)`))
if (match) { row.length = Number(match[1]); row.width = Number(match[2]); row.height = Number(match[3]) }
const coefficient = remark.match(new RegExp(`${row.boxType}(?::[^;]*)?;系数:(\\d+(?:\\.\\d+)?)`))
row.coefficient = coefficient ? Number(coefficient[1]) : fallbackCoefficient
const note = remark.match(new RegExp(`${BOX_NAMES[row.boxType]}:([^;]+)`))
if (note) row.remark = note[1]
})
}
catch (error) { ElMessage.error(getApiErrorDetails(error, '客户计费规则加载失败').message) }
finally { chargeLoading.value = false }
}
onMounted(() => { void loadConfig(); void loadChargeRules() })
</script>
<template>
@@ -186,8 +291,14 @@ onMounted(loadConfig)
<div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>请联系管理员申请尺寸计算规则查看权限。</p></div>
<div v-else-if="loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
<section v-else-if="config" class="rule-card">
<header class="rule-card-heading"><h2>按箱规尺寸计算</h2><el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button></header>
<div class="formula-band"><span>计算方式:<strong>内径尺寸 + 外边加宽 × 2 = 外径尺寸</strong></span><span>体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</span><span>默认加边:2cm(每侧)</span></div>
<header class="rule-card-heading">
<div class="rule-title-row">
<h2>按箱规尺寸计算</h2>
<el-checkbox :model-value="defaultCalculationMode === 'DIMENSION'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('DIMENSION')">设为默认规则</el-checkbox>
</div>
<el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button>
</header>
<div class="formula-band"><span>计算方式:<strong>内径尺寸 + 外边加宽 × 2 = 外径尺寸</strong>(按实测尺寸匹配最小可容纳箱规)</span><span>体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</span><span>超出当前箱规时自动匹配下一档箱规</span></div>
<DataTable :data="config.rules" :columns="columns" :loading="loading" row-key="boxType" empty-text="当前客户暂无箱规尺寸规则">
<template #boxTypeName="{ row }"><strong>{{ row.boxTypeName }}</strong></template>
<template #inner="{ row }">{{ formatDimension(row.innerLengthCm, row.innerWidthCm, row.innerHeightCm) }}</template>
@@ -199,18 +310,34 @@ onMounted(loadConfig)
</section>
<div v-else v-loading="loading" class="loading-state" />
<AppDialog v-model="editorVisible" title="编辑箱规尺寸" width="min(1100px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
<section v-if="canChargeView" class="rule-card charge-rule-card">
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算</h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT')">设为默认规则</el-checkbox></div><p class="section-hint">勾选的规则将作为该客户订单“外箱规数据”的默认计算方式,同一时间只能选择一个;权重系数用于调整不同箱型的计费体积:加权体积 = 体积 × 权重系数,体积重 = 加权体积 × 167 kg/CBM。</p></div></header>
<el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置">
<el-table-column prop="boxTypeName" label="箱规类型" min-width="110" />
<el-table-column label="内径尺寸(长×宽×高 cm)" min-width="240"><template #default="{ row }">{{ row.dimension ? formatDimension(row.dimension.innerLengthCm, row.dimension.innerWidthCm, row.dimension.innerHeightCm) : '—' }}</template></el-table-column>
<el-table-column label="权重系数" min-width="110"><template #default="{ row }"><strong>{{ row.coefficient }}</strong></template></el-table-column>
<el-table-column label="体积(CBM)" min-width="120"><template #default="{ row }">{{ row.dimension ? Number(row.dimension.volumeCbm).toFixed(3) : '—' }}</template></el-table-column>
<el-table-column label="加权体积(CBM)" min-width="145"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient).toFixed(3) : '—' }}</template></el-table-column>
<el-table-column label="重量(kg)" min-width="105"><template #default="{ row }">{{ row.weight ? row.weight.toFixed(2) : '—' }}</template></el-table-column>
<el-table-column label="体积重(kg)" min-width="115"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient * 167).toFixed(2) : '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" align="left" header-align="left" class-name="charge-remark-column" label-class-name="charge-remark-column"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
</el-table>
</section>
<AppDialog v-model="editorVisible" title="编辑箱规尺寸 / 计算规则" width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
<div class="editor-content">
<p class="editor-formula">计算公式:外径 = 内径 + 加宽 × 2(每侧加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
<p class="editor-formula">计算公式:按长、宽、高匹配最小可容纳箱规;外径 = 该箱规内径 + 加宽 × 2(每侧加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
<el-form ref="editorForm" :model="form" label-position="top">
<div class="editor-table-wrap">
<table class="editor-table"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>外边加宽(cm)</th><th>备注</th></tr></thead><tbody>
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr>
</tbody></table>
</div>
<div class="rule-save-row"><el-button type="primary" :loading="editorSaving" @click="saveConfig"><Save :size="16" />保存规则</el-button></div>
<section v-if="canChargeView" class="combined-charge-editor"><h3>箱规按权重系数计算</h3><p class="editor-formula">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="charge-rule-editor"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th>{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr></tbody></table></div><div class="rule-save-row"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig"><Save :size="16" />保存系数规则</el-button></div></section>
</el-form>
</div>
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="editorSaving" @click="saveConfig"><Save :size="16" />保存规则</el-button></div></template>
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
</AppDialog>
</div>
</template>
@@ -221,8 +348,22 @@ onMounted(loadConfig)
.rule-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.rule-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 13px; }
.heading-actions,.dialog-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }.heading-actions :deep(.el-button),.dialog-actions :deep(.el-button) { margin-left: 0; }
.rule-card { min-width: 0; }.rule-card-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }.rule-card-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.formula-band { display: flex; min-height: 52px; align-items: center; flex-wrap: wrap; gap: 12px 30px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 13px; }.formula-band strong { color: #344054; }.loading-state { min-height: 280px; }
.rule-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }.rule-title-row :deep(.el-checkbox) { height: 30px; margin-right: 0; padding: 0 10px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }.rule-title-row :deep(.el-checkbox.is-checked) { border-color: var(--cc-primary); background: var(--cc-primary-soft); }
.rule-state { display: flex; min-height: 220px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #8797b2; text-align: center; }.rule-state strong { color: #475467; font-size: 13px; }.rule-state p { margin: 0; color: var(--cc-muted); font-size: 12px; }
.rule-error { display: flex; min-height: 180px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px; background: #fff4f3; }.rule-error strong { color: #7a271a; }.rule-error p { margin: 5px 0; color: #b42318; font-size: 12px; }
.volume-value { color: #1570ef; }.editor-content { display: grid; gap: 20px; }.editor-formula { margin: 0; padding: 13px 15px; color: #475467; font-size: 13px; line-height: 1.7; }.editor-table-wrap { overflow-x: auto; }.editor-table { width: 100%; min-width: 920px; border-collapse: collapse; }.editor-table th,.editor-table td { padding: 10px 12px; border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table thead th { background: #eef0fa; color: #475467; font-size: 12px; }.editor-table tbody th { color: var(--cc-text); font-size: 13px; }.editor-table :deep(.el-input-number),.editor-table :deep(.el-input) { width: 140px; }.editor-table :deep(.el-input-number .el-input__wrapper) { width: 100%; }
.charge-rule-card { margin-top: 18px; }.section-hint { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; font-weight: 400; }.charge-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.charge-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.charge-form :deep(.el-input-number), .charge-form :deep(.el-input) { width: 100%; }
.charge-form--simple { grid-template-columns: 1fr; }.charge-form--simple :deep(.el-form-item) { grid-column: 1; }
.charge-rule-editor-wrap { overflow-x: auto; }.charge-rule-editor { width: 100%; min-width: 920px; border-collapse: collapse; }.charge-rule-editor th,.charge-rule-editor td { padding: 9px 10px; border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor thead th { background: #eef0fa; color: #475467; font-size: 12px; }.charge-rule-editor tbody th { color: var(--cc-text); font-size: 13px; }.charge-rule-editor :deep(.el-input-number),.charge-rule-editor :deep(.el-input) { width: 118px; }.charge-rule-editor :deep(.el-input-number .el-input__wrapper) { width: 100%; }
.charge-box-cell { min-height: 34px; display: flex; align-items: center; border-bottom: 1px solid var(--cc-border); }.charge-box-cell:last-child { border-bottom: 0; }
.rule-save-row { display: flex; justify-content: flex-end; padding: 12px 0 4px; }
.dialog-actions { justify-content: center; width: 100%; }
.dialog-actions :deep(.el-button) { min-width: 112px; min-height: 46px; font-size: 15px; }
.charge-rule-card :deep(.el-table) { color: #344054; font-size: 13px; }
.charge-rule-card :deep(.el-table th.el-table__cell) { background: #f8fafc; color: #475467; font-size: 13px; font-weight: 650; }
.charge-rule-card :deep(.el-table td.el-table__cell) { color: #344054; font-size: 13px; }
.charge-rule-card :deep(.el-table .cell) { line-height: 1.5; text-align: center; }
.charge-rule-card :deep(.el-table .charge-remark-column .cell) { text-align: left; }
.charge-rule-card :deep(.el-table strong) { color: #1570ef; font-weight: 650; }
@media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; }.dialog-actions { width: 100%; }.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; } }
</style>
@@ -168,6 +168,13 @@ describe('CustomersView', () => {
vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(false)
})
it('waits for the client workspace to confirm impersonation session persistence', () => {
expect(customersSource).toContain("searchParams.set('impersonationOrigin', window.location.origin)")
expect(customersSource).toContain("event.data?.type !== 'impersonation-complete'")
expect(customersSource).toContain("event.data?.type === 'impersonation-failed'")
expect(customersSource).toContain("ElMessage.success('客户工作台登录成功')")
})
it('searches the customer list by customer name only', () => {
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
+30 -11
View File
@@ -777,12 +777,19 @@ async function submitImpersonation() {
impersonationError.value = getApiErrorDetails(new Error('未配置客户工作台地址'), '暂未配置客户工作台地址,无法安全跳转登录')
return
}
let targetOrigin: string
try { targetOrigin = new URL(clientUrl, window.location.origin).origin } catch {
let targetUrl: URL
try {
targetUrl = new URL(clientUrl, window.location.origin)
targetUrl.pathname = `${targetUrl.pathname.replace(/\/+$/, '')}/login`
targetUrl.search = ''
targetUrl.hash = ''
targetUrl.searchParams.set('impersonationOrigin', window.location.origin)
} catch {
impersonationError.value = getApiErrorDetails(new Error('客户工作台地址无效'), '客户工作台地址配置无效,无法安全跳转登录')
return
}
const popup = window.open(clientUrl, '_blank')
const targetOrigin = targetUrl.origin
const popup = window.open(targetUrl.toString(), '_blank')
if (!popup) {
impersonationError.value = getApiErrorDetails(new Error('浏览器拦截了新窗口'), '浏览器拦截了客户工作台窗口,请允许弹窗后重试')
return
@@ -792,27 +799,39 @@ async function submitImpersonation() {
let handoffPending = false
try {
const ticket = await createPlatformTenantImpersonation(impersonationTenant.value.id, { reason: impersonationForm.reason.trim() })
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.origin !== targetOrigin || event.data?.source !== 'chongchong-client' || event.data?.type !== 'impersonation-ready') return
popup.postMessage({ source: 'chongchong-admin', type: 'impersonation-ticket', code: ticket.code }, targetOrigin)
let ticketSent = false
const onMessage = (event: MessageEvent) => {
if (event.source !== popup || event.origin !== targetOrigin || event.data?.source !== 'chongchong-client') return
if (event.data?.type === 'impersonation-ready' && !ticketSent) {
ticketSent = true
popup.postMessage({ source: 'chongchong-admin', type: 'impersonation-ticket', code: ticket.code }, targetOrigin)
return
}
if (event.data?.type === 'impersonation-failed') {
cleanup()
impersonationSubmitting.value = false
impersonationError.value = getApiErrorDetails(new Error('客户工作台未写入代登录会话'), '客户工作台登录写入失败,请关闭窗口后重试')
return
}
if (event.data?.type !== 'impersonation-complete') return
cleanup()
impersonationSubmitting.value = false
ElMessage.success('客户工作台已打开')
ElMessage.success('客户工作台登录成功')
impersonationDialogOpen.value = false
}
const timeout = window.setTimeout(() => {
cleanup()
impersonationSubmitting.value = false
impersonationError.value = getApiErrorDetails(new Error('客户工作台未完成安全握手'), '客户工作台未完成安全握手,请关闭窗口后重试')
}, 10000)
impersonationError.value = getApiErrorDetails(new Error('客户工作台未确认会话写入'), '客户工作台未确认登录成功,请关闭窗口后重试')
}, 15000)
const cleanup = () => {
window.clearTimeout(timeout)
window.removeEventListener('message', onReady)
window.removeEventListener('message', onMessage)
if (impersonationCleanup === cleanup) impersonationCleanup = null
}
impersonationCleanup?.()
impersonationCleanup = cleanup
window.addEventListener('message', onReady)
window.addEventListener('message', onMessage)
handoffPending = true
} catch (error) {
popup.close()
+14
View File
@@ -1,6 +1,7 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
CustomerDimensionConfig,
CustomerDimensionCalculationMode,
CustomerDimensionRule,
UpdateCustomerDimensionConfigRequest,
} from './types'
@@ -23,6 +24,7 @@ function normalizeRule(rule: RawRule): CustomerDimensionRule {
function normalizeConfig(config: RawConfig): CustomerDimensionConfig {
return {
tenantId: String(config.tenantId),
defaultCalculationMode: config.defaultCalculationMode === 'MULTIPLY' ? 'MULTIPLY' : 'ADD',
rules: config.rules.map(normalizeRule),
}
}
@@ -43,3 +45,15 @@ export async function updateCustomerDimensionConfig(
})
return normalizeConfig(unwrapApiResponse(response))
}
export async function updateCustomerDimensionDefaultCalculationMode(
tenantId: string,
defaultCalculationMode: CustomerDimensionCalculationMode,
): Promise<CustomerDimensionConfig> {
const response = await http.put<never, ApiEnvelope<RawConfig>>(
`${dimensionConfigPath}/default-calculation-mode`,
{ defaultCalculationMode },
{ params: { tenantId } },
)
return normalizeConfig(unwrapApiResponse(response))
}
+2
View File
@@ -1,10 +1,12 @@
export {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
updateCustomerDimensionDefaultCalculationMode,
} from './api'
export type {
CustomerDimensionBoxType,
CustomerDimensionCalculationMode,
CustomerDimensionConfig,
CustomerDimensionConfigFormModel,
CustomerDimensionRule,
+2
View File
@@ -1,4 +1,5 @@
export type CustomerDimensionBoxType = 'SMALL' | 'MEDIUM' | 'LARGE' | 'EXTRA_LARGE'
export type CustomerDimensionCalculationMode = 'ADD' | 'MULTIPLY'
export interface CustomerDimensionRule {
id: string
@@ -20,6 +21,7 @@ export interface CustomerDimensionRule {
export interface CustomerDimensionConfig {
tenantId: string
defaultCalculationMode: CustomerDimensionCalculationMode
rules: CustomerDimensionRule[]
}
@@ -28,6 +28,9 @@ export interface PlatformExportTask {
contentType: string | null
sizeBytes: number | null
failureMessage: string | null
attemptCount: number
maxAttempts: number
nextRetryAt: string | null
requestedBy: string
requestedUsername: string | null
requestedAt: string
@@ -30,7 +30,8 @@ const task: PlatformExportTask = {
id: '9', taskNo: 'EXP9', exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表',
status: 'SUCCEEDED', totalRows: 12, processedRows: 12, fileName: '客户列表.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', sizeBytes: 2048,
failureMessage: null, requestedBy: '1', requestedUsername: 'admin', requestedAt: '2026-08-03T10:00:00',
failureMessage: null, attemptCount: 1, maxAttempts: 3, nextRetryAt: null,
requestedBy: '1', requestedUsername: 'admin', requestedAt: '2026-08-03T10:00:00',
startedAt: '2026-08-03T10:00:01', completedAt: '2026-08-03T10:00:02', expiresAt: '2026-08-10T10:00:02',
}
@@ -85,7 +86,7 @@ describe('DownloadCenterView', () => {
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:export')
})
it('shows retry only for failed tasks with the source export permission', async () => {
it('shows retry for failed tasks with the source export permission', async () => {
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [{ ...task, status: 'FAILED', failureMessage: 'IAM timeout' }], page: 1, size: 20, total: 1, pages: 1 })
const pinia = setActivePinia(createPinia())
const auth = useAuthStore()
@@ -102,6 +103,27 @@ describe('DownloadCenterView', () => {
expect(retryPlatformExportTask).toHaveBeenCalledWith('9')
})
it('shows waiting retry and allows an immediate retry during automatic backoff', async () => {
vi.mocked(listPlatformExportTasks).mockResolvedValue({
records: [{ ...task, status: 'PENDING', processedRows: 0, failureMessage: '头程服务查询失败', attemptCount: 2, nextRetryAt: '2026-09-03T10:34:28' }],
page: 1, size: 20, total: 1, pages: 1,
})
const pinia = setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:download:list', 'platform:customer:export']
useAccessPreviewStore().initializeAfterLogin()
mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.body.textContent).toContain('等待重试')
const retry = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((item) => item.textContent?.trim() === '立即重试')
retry?.click()
await flushPromises()
expect(retryPlatformExportTask).toHaveBeenCalledWith('9')
})
it('labels and retries Finance tasks with their exact source export permission', async () => {
const financeTask = {
...task,
@@ -222,6 +222,13 @@ function statusMeta(status: ExportTaskStatus) {
return values[status] ?? { label: status || '未知状态', type: 'info' as const }
}
function taskStatusMeta(task: PlatformExportTask) {
if (task.status === 'PENDING' && task.attemptCount > 0) {
return { label: '等待重试', type: 'warning' as const }
}
return statusMeta(task.status)
}
function formatDate(value: string | null) {
if (!value) return '—'
const date = new Date(value)
@@ -237,7 +244,12 @@ function formatSize(bytes: number | null) {
function canRetry(task: PlatformExportTask) {
const permission = exportTypeMeta[task.exportType]?.retryPermission
return task.status === 'FAILED' && permission !== undefined && access.can(permission)
const retryableStatus = task.status === 'FAILED' || (task.status === 'PENDING' && task.attemptCount > 0)
return retryableStatus && permission !== undefined && access.can(permission)
}
function retryLabel(task: PlatformExportTask) {
return task.status === 'PENDING' ? '立即重试' : '重试'
}
watch([page, pageSize], () => { void loadTasks() })
@@ -275,10 +287,10 @@ onBeforeUnmount(() => {
<template #actions="{ row }"><div class="table-actions">
<el-button v-if="(row as PlatformExportTask)?.id" link type="primary" @click="openDetail(row as PlatformExportTask)"><Eye :size="15" />详情</el-button>
<el-button v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" link type="primary" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)"><Download :size="15" />下载</el-button>
<el-button v-if="canRetry(row as PlatformExportTask)" link type="primary" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)"><RotateCcw :size="15" />重试</el-button>
<el-button v-if="canRetry(row as PlatformExportTask)" link type="primary" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)"><RotateCcw :size="15" />{{ retryLabel(row as PlatformExportTask) }}</el-button>
</div></template>
<template #type="{ row }">{{ typeLabel((row as PlatformExportTask).exportType) }}</template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformExportTask).status).type" effect="light">{{ statusMeta((row as PlatformExportTask).status).label }}</el-tag></template>
<template #status="{ row }"><el-tag :type="taskStatusMeta(row as PlatformExportTask).type" effect="light">{{ taskStatusMeta(row as PlatformExportTask).label }}</el-tag></template>
<template #progress="{ row }"><span>{{ (row as PlatformExportTask).processedRows }}<template v-if="(row as PlatformExportTask).totalRows !== null"> / {{ (row as PlatformExportTask).totalRows }}</template></span><small v-if="(row as PlatformExportTask).sizeBytes !== null">{{ formatSize((row as PlatformExportTask).sizeBytes) }}</small></template>
<template #requester="{ row }">{{ (row as PlatformExportTask).requestedUsername || (row as PlatformExportTask).requestedBy }}</template>
<template #requestedAt="{ row }">{{ formatDate((row as PlatformExportTask).requestedAt) }}</template>
@@ -309,7 +321,7 @@ onBeforeUnmount(() => {
<h3>{{ detailTask.taskName }}</h3>
<p>{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}</p>
</div>
<el-tag :type="statusMeta(detailTask.status).type" effect="light">{{ statusMeta(detailTask.status).label }}</el-tag>
<el-tag :type="taskStatusMeta(detailTask).type" effect="light">{{ taskStatusMeta(detailTask).label }}</el-tag>
</div>
<dl class="task-detail-grid">
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo }}</dd></div>
@@ -317,6 +329,8 @@ onBeforeUnmount(() => {
<div><dt>申请时间</dt><dd>{{ formatDate(detailTask.requestedAt) }}</dd></div>
<div><dt>开始时间</dt><dd>{{ formatDate(detailTask.startedAt) }}</dd></div>
<div><dt>完成时间</dt><dd>{{ formatDate(detailTask.completedAt) }}</dd></div>
<div><dt>执行次数</dt><dd>{{ detailTask.attemptCount }} / {{ detailTask.maxAttempts }}</dd></div>
<div><dt>下次重试</dt><dd>{{ detailTask.status === 'PENDING' && detailTask.attemptCount > 0 ? formatDate(detailTask.nextRetryAt) : '—' }}</dd></div>
<div><dt>过期时间</dt><dd>{{ formatDate(detailTask.expiresAt) }}</dd></div>
<div><dt>处理数据</dt><dd>{{ detailTask.processedRows }}<template v-if="detailTask.totalRows !== null"> / {{ detailTask.totalRows }}</template> 行</dd></div>
<div><dt>文件大小</dt><dd>{{ formatSize(detailTask.sizeBytes) || '—' }}</dd></div>
@@ -330,6 +344,11 @@ onBeforeUnmount(() => {
<template #footer="{ close }">
<div class="app-dialog__footer">
<el-button @click="close">关闭</el-button>
<el-button
v-if="detailTask && canRetry(detailTask)"
:loading="retryingId === detailTask.id"
@click="retryTask(detailTask)"
>{{ retryLabel(detailTask) }}</el-button>
<el-button
type="primary"
:loading="downloadingId === detailTask?.id"
@@ -0,0 +1,65 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface CustomerChargeRule {
id: string
tenantId: string
ruleCode: string
ruleVersion: number
customerId: string
customerName: string
businessType: string
chargeMode: 'KG' | 'CBM'
taxMode: 'TAX_INCLUDED' | 'TAX_EXCLUDED'
conversionFactor: number
lengthFactor: number
widthFactor: number
heightFactor: number
defaultRule: boolean
roundingScale: number
roundingMode: string
currency: string
effectiveFrom: string
effectiveTo: string | null
status: 'ACTIVE' | 'INACTIVE'
remark: string | null
}
export type CreateCustomerChargeRuleRequest = Omit<CustomerChargeRule, 'id' | 'tenantId' | 'ruleVersion' | 'status'> & {
id?: never
tenantId?: never
ruleVersion?: never
status?: never
}
const path = '/platform/v1/fba-pricing/customer-rules'
function normalize(value: CustomerChargeRule): CustomerChargeRule {
return {
...value,
id: String(value.id), tenantId: String(value.tenantId), customerId: String(value.customerId),
ruleVersion: Number(value.ruleVersion), conversionFactor: Number(value.conversionFactor),
lengthFactor: Number(value.lengthFactor ?? 1), widthFactor: Number(value.widthFactor ?? 1),
heightFactor: Number(value.heightFactor ?? 1), defaultRule: Boolean(value.defaultRule),
roundingScale: Number(value.roundingScale),
}
}
export async function listCustomerChargeRules(customerId: string, tenantId?: string) {
const response = await http.get<never, ApiEnvelope<CustomerChargeRule[]>>(path, {
params: { tenantId: tenantId || customerId, customerId },
})
return unwrapApiResponse(response).map(normalize)
}
export async function createCustomerChargeRule(tenantId: string, request: CreateCustomerChargeRuleRequest) {
const response = await http.post<never, ApiEnvelope<CustomerChargeRule>>(`${path}/versions`, request, { params: { tenantId } })
return normalize(unwrapApiResponse(response))
}
export async function setCustomerChargeRuleDefault(tenantId: string, ruleId: string) {
await http.put<never, ApiEnvelope<void>>(`${path}/${encodeURIComponent(ruleId)}/default`, undefined, { params: { tenantId } })
}
export async function retireCustomerChargeRule(tenantId: string, ruleId: string) {
await http.delete<never, ApiEnvelope<void>>(`${path}/${encodeURIComponent(ruleId)}`, { params: { tenantId } })
}
+1
View File
@@ -0,0 +1 @@
export * from './public'
+7
View File
@@ -0,0 +1,7 @@
export {
createCustomerChargeRule,
listCustomerChargeRules,
retireCustomerChargeRule,
setCustomerChargeRuleDefault,
} from './customer-rules/api'
export type { CustomerChargeRule, CreateCustomerChargeRuleRequest } from './customer-rules/api'
+2 -1
View File
@@ -15,7 +15,8 @@
## 费用付款与凭证快速入口
- 入库费用的应付/直接已付表单在 `../unified-orders/components/InboundFeeDetailManager.vue`,柜次费用在 `../loading-lists/components/ContainerFeesPanel.vue`;柜级公共费用在同一面板按真实客户与订单分摊,入仓码按事实选填;对应 API 分别为 `../unified-orders/api/inboundFeeDetails.ts`、`../loading-lists/api/containerFees.ts`。
- 入库费用的应付/直接已付表单在 `../unified-orders/components/InboundFeeDetailManager.vue`,柜次费用在 `../loading-lists/components/ContainerFeesPanel.vue`;柜级公共费用在同一面板按服务端返回的真实客户与订单分摊,入仓 ID/入仓码同时选填或同时为空;对应 API 分别为 `../unified-orders/api/inboundFeeDetails.ts`、`../loading-lists/api/containerFees.ts`。
- 柜级分摊请求的各行金额之和必须精确等于公共费用总额;创建后使用 `allocationGroupId` 整组审核、驳回、调整或作废,前端不得调用单行接口造成部分成功,也不得提交柜外客户、订单或入仓归属。
- `DIRECT_PAID` 必须提交已付时间、付款方式,以及付款流水号或至少一个付款凭证;凭证由费用 API 调用 `/platform/finance/v1/vouchers` 上传为 `FINANCE_VOUCHER`,并绑定当前客户。
- 后端事实入口为 Firstleg `FeePaymentSnapshotValidator`、入库/柜次费用服务,文件元数据归 File;Finance 只消费已发布的费用来源快照,不在前端或 Finance 中重建付款事实。
- 财务与柜级公共费用分摊统一从 `pages/` 和 `api/finance.ts` 进入;`allocationGroupId` 整组审核、驳回、调整或作废,不允许按单条分摊明细绕过整组状态机。
@@ -14,6 +14,7 @@ import {
} from '@/shared/api/http'
import {
adjustPublishedInboundFee,
calculateInboundFee,
confirmInboundFee,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
@@ -21,6 +22,8 @@ import {
sendInboundFee,
} from './api'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
import { getInboundMeasurement } from '../measurement/api'
import type { InboundMeasurementDetail } from '../measurement/types'
const props = defineProps<{
customerId?: string
@@ -44,6 +47,10 @@ const adjustmentError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const snapshot = ref<InboundFeeSnapshot | null>(null)
const adjustOpen = ref(false)
const trialOpen = ref(false)
const trialLoading = ref(false)
const measurement = ref<InboundMeasurementDetail | null>(null)
const trial = reactive({ weightSource: 'ACTUAL', volumeSource: 'ACTUAL' })
const adjustFormRef = ref<FormInstance>()
let loadSequence = 0
let adjustmentFingerprint = ''
@@ -71,6 +78,7 @@ const canView = computed(() => access.can('firstleg:fba-pricing:view'))
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
const canCalculate = computed(() => access.can('firstleg:fba-pricing:calculate'))
const customerContextId = computed(() => props.customerId || props.tenantId || '')
const normalizedInboundId = computed(() => {
const identifier = String(props.inboundId ?? '').trim()
@@ -83,6 +91,18 @@ const adjustmentEligible = computed(() => (
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
const sourceOptions = computed(() => {
const value = measurement.value
if (!value) return []
const itemWeight = value.items.reduce((sum, item) => sum + Number(item.totalWeightKg || 0), 0)
const itemVolume = value.items.reduce((sum, item) => sum + Number(item.totalVolumeCbm || 0), 0)
return [
{ value: 'CUSTOMER', label: '客户下单数据', weight: Number(value.originalGrossWeightKg || 0), volume: Number(value.originalVolumeCbm || 0) },
{ value: 'OUTER', label: '外箱规数据', weight: itemWeight, volume: itemVolume },
{ value: 'ACTUAL', label: '实际测量数据', weight: Number(value.actualNetWeightKg || value.actualGrossWeightKg || 0), volume: Number(value.actualVolumeCbm || 0) },
{ value: 'WAREHOUSE', label: '仓库建议数据', weight: itemWeight, volume: itemVolume },
]
})
function feeStatusInfo(status?: string) {
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
@@ -177,6 +197,38 @@ function openAdjustment() {
adjustOpen.value = true
}
async function openTrial() {
trialLoading.value = true
try { measurement.value = await getInboundMeasurement(customerContextId.value, normalizedInboundId.value); trial.weightSource = 'ACTUAL'; trial.volumeSource = 'ACTUAL'; trialOpen.value = true }
catch (error) { ElMessage.error(getApiErrorDetails(error, '测量数据加载失败,无法试算').message) }
finally { trialLoading.value = false }
}
async function submitTrial() {
const weight = sourceOptions.value.find((item) => item.value === trial.weightSource)
const volume = sourceOptions.value.find((item) => item.value === trial.volumeSource)
if (!weight?.weight || !volume?.volume) { ElMessage.warning('所选重量或体积没有有效数据'); return }
trialLoading.value = true
try {
const result = await calculateInboundFee(customerContextId.value, normalizedInboundId.value, { selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume, weightSource: weight.value, volumeSource: volume.value }, createFeeActionIdempotencyKey('calculate'))
snapshot.value = result; trialOpen.value = false; emit('changed', result); ElMessage.success('费用试算已更新')
} catch (error) { adjustmentError.value = getApiErrorDetails(error, '费用试算失败') }
finally { trialLoading.value = false }
}
async function confirmAndPush() {
if (!snapshot.value || feeAction.value) return
try { await ElMessageBox.confirm('将确认当前试算结果并立即推送给客户,是否继续?', '确认推送费用', { type: 'warning', confirmButtonText: '确认推送' }) } catch { return }
feeAction.value = 'confirm'
try {
await confirmInboundFee(customerContextId.value, normalizedInboundId.value, createFeeActionIdempotencyKey('confirm'))
feeAction.value = 'send'
const result = await sendInboundFee(customerContextId.value, normalizedInboundId.value, createFeeActionIdempotencyKey('send'))
snapshot.value = result; emit('changed', result); ElMessage.success('费用已确认并推送给客户')
} catch (error) { adjustmentError.value = getApiErrorDetails(error, '确认推送费用失败'); await loadFee() }
finally { feeAction.value = null }
}
async function transitionFee(action: 'confirm' | 'send') {
if (!snapshot.value || feeAction.value) return
const confirming = action === 'confirm'
@@ -299,6 +351,13 @@ defineExpose({ reload: loadFee })
@click="loadFee()"
><RefreshCw :size="16" /></el-button>
</el-tooltip>
<el-button
v-if="canCalculate"
type="primary"
plain
:loading="trialLoading"
@click="openTrial"
><CircleDollarSign :size="16" />费用试算</el-button>
<el-button
v-if="canAdjust && adjustmentEligible"
data-testid="adjust-fee-button"
@@ -313,6 +372,13 @@ defineExpose({ reload: loadFee })
:disabled="Boolean(feeAction)"
@click="transitionFee('confirm')"
><Check :size="16" />确认费用</el-button>
<el-button
v-if="canConfirm && canSend && snapshot?.feeStatus === 'CALCULATED'"
type="primary"
:loading="Boolean(feeAction)"
:disabled="Boolean(feeAction)"
@click="confirmAndPush"
><Send :size="16" />确认推送费用</el-button>
<el-button
v-if="canSend && snapshot?.feeStatus === 'CONFIRMED'"
data-testid="send-fee-button"
@@ -324,6 +390,14 @@ defineExpose({ reload: loadFee })
</div>
</header>
<AppDialog v-model="trialOpen" title="头程费用试算" width="min(760px, calc(100vw - 28px))" :confirm-loading="trialLoading" confirm-text="开始试算" @confirm="submitTrial">
<el-alert title="重量和体积各选择一项;默认选择实际测量数据。" type="info" :closable="false" show-icon />
<div class="trial-source-grid">
<section><h4>计费重量(单选)</h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
<section><h4>计费体积(单选)</h4><el-radio-group v-model="trial.volumeSource"><el-radio v-for="item in sourceOptions" :key="`volume-${item.value}`" :value="item.value" :disabled="item.volume <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.volume, 6) }} CBM</strong></el-radio></el-radio-group></section>
</div>
</AppDialog>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
<template #title>当前账号没有查看入库费用的权限</template>
<p>需要权限:firstleg:fba-pricing:view</p>
@@ -539,6 +613,7 @@ defineExpose({ reload: loadFee })
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
.adjustment-total strong { justify-self: center; font-size: 18px; white-space: nowrap; }
.adjustment-total small { color: var(--cc-muted); font-size: 11px; text-align: right; }
.trial-source-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 16px; }.trial-source-grid section { padding: 14px; border: 1px solid var(--cc-border); border-radius: 8px; }.trial-source-grid h4 { margin: 0 0 12px; }.trial-source-grid :deep(.el-radio-group) { display: grid; gap: 10px; }.trial-source-grid :deep(.el-radio) { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; height: auto; margin: 0; padding: 10px; border: 1px solid var(--cc-border); border-radius: 6px; }.trial-source-grid :deep(.el-radio__label) { display: flex; min-width: 0; justify-content: space-between; gap: 10px; }.trial-source-grid strong { color: var(--cc-text); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 900px) {
+9 -1
View File
@@ -15,6 +15,14 @@ export async function getLatestInboundFee(tenantId: string, inboundId: string) {
return unwrapApiResponse(response)
}
export async function calculateInboundFee(tenantId: string, inboundId: string, payload: { selectedWeightKg: number; selectedVolumeCbm: number; weightSource: string; volumeSource: string }, idempotencyKey: string) {
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
`${feeBasePath(inboundId)}/calculate`, payload,
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
async function transitionInboundFee(
tenantId: string,
inboundId: string,
@@ -55,7 +63,7 @@ export function createFeeAdjustmentIdempotencyKey() {
return createFeeActionIdempotencyKey('adjust')
}
export function createFeeActionIdempotencyKey(action: 'adjust' | 'confirm' | 'send') {
export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'confirm' | 'send') {
const uuid = globalThis.crypto?.randomUUID?.()
if (uuid) return `fee-${action}-${uuid}`
return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
+9 -1
View File
@@ -1,7 +1,15 @@
export { getInboundMeasurement } from './measurement/api'
export { getInboundMeasurement, saveInboundMeasurementDraft } from './measurement/api'
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
export {
calculateInboundFee,
confirmInboundFee,
createFeeActionIdempotencyKey,
getLatestInboundFee,
sendInboundFee,
} from './fee/api'
export type { InboundFeeSnapshot } from './fee/types'
export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types'
@@ -9,6 +9,7 @@ const props = defineProps<{
customerId: string
inboundId: string
inboundCode?: string | null
openWarehouseSuggestion?: boolean
}>()
const emit = defineEmits<{
@@ -84,6 +85,7 @@ function handleSubmitted() {
ref="measurementPanelRef"
:customer-id="customerId"
:inbound-id="inboundId"
:open-warehouse-suggestion="openWarehouseSuggestion"
@updated="handleUpdated"
@saved="handleUpdated"
@submitted="handleSubmitted"
@@ -277,6 +277,22 @@ describe('InboundMeasurementPanel', () => {
expect(payload).not.toHaveProperty('originalVolumeCbm')
})
it('saves all three warehouse outer dimensions against the measured sequence', async () => {
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('001')
await wrapper.find('input[aria-label="仓库修改外箱长"]').setValue('55')
await wrapper.find('input[aria-label="仓库修改外箱宽"]').setValue('45')
await wrapper.find('input[aria-label="仓库修改外箱高"]').setValue('35')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click')
await flushPromises()
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2]
expect(payload?.items[0]).toMatchObject({
itemNo: '001', outerLengthCm: 55, outerWidthCm: 45, outerHeightCm: 35,
})
})
it('updates the server weight summary without discarding unsaved measurement edits', async () => {
const wrapper = await mountPanel()
const countInput = wrapper.find('.measurement-count-editor input')
@@ -5,10 +5,12 @@ import { AlertTriangle, Image, Plus, Save, Send, Upload } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
import type {
InboundMeasurementDetail,
InboundMeasurementItem,
@@ -19,6 +21,7 @@ const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
openWarehouseSuggestion?: boolean
}>()
const emit = defineEmits<{
@@ -28,10 +31,12 @@ const emit = defineEmits<{
submitted: []
}>()
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coefficientMode'> & {
itemId: string
clientKey: string
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
chargeRuleId: string | null
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
}
interface MeasurementFormModel {
@@ -57,10 +62,15 @@ const loading = ref(false)
const saving = ref(false)
const submitting = ref(false)
const manualEntryOpen = ref(false)
const warehouseSuggestionOpen = ref(false)
const warehouseSuggestionAutoOpened = ref(false)
const warehouseSuggestion = reactive({ itemId: '', packageCount: 1, remark: '', coefficientMode: 'ADD' as 'ADD' | 'MULTIPLY' })
const manualEntrySubmitting = ref(false)
const manualEntryImage = ref<File | null>(null)
const manualEntryErrors = ref<string[]>([])
const detail = ref<InboundMeasurementDetail | null>(null)
const chargeRules = ref<CustomerChargeRule[]>([])
const chargeRulesLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const operationError = ref<ApiErrorDetails | null>(null)
const conflictDetected = ref(false)
@@ -95,6 +105,7 @@ const canView = computed(() => access.can('platform:firstleg:inbound:measure:vie
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
const canWarehouseSuggestion = computed(() => access.can('platform:firstleg:inbound:warehouse-suggestion:edit'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const countEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
@@ -112,6 +123,9 @@ const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== basel
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未测量')))
const suggestionItem = computed(() => form.items.find((item) => item.itemId === warehouseSuggestion.itemId) || null)
const suggestionTotalVolume = computed(() => Number(suggestionItem.value?.serverValues.unitVolumeCbm || 0) * warehouseSuggestion.packageCount)
const suggestionGirth = computed(() => suggestionItem.value ? suggestionItem.value.lengthCm + 2 * (suggestionItem.value.widthCm + suggestionItem.value.heightCm) : 0)
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
@@ -203,6 +217,7 @@ async function submitManualEntry() {
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
return {
itemId: String(item.id),
inboundDetailId: String(item.inboundDetailId || item.id),
clientKey: nextItemKey(),
boxCode: item.boxCode,
itemNo: item.itemNo,
@@ -227,9 +242,41 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
totalVolumeCbm: item.totalVolumeCbm,
volumeWeightKg: item.volumeWeightKg,
},
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
coefficientMode: item.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL',
}
}
function openWarehouseSuggestion() {
if (!canWarehouseSuggestion.value && !canEdit.value) return
const first = form.items[0]
warehouseSuggestion.itemId = first?.itemId || ''
warehouseSuggestion.packageCount = first?.packageCount || 1
warehouseSuggestion.remark = first?.remark || ''
warehouseSuggestion.coefficientMode = first?.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : 'ADD'
warehouseSuggestionOpen.value = true
}
function applyWarehouseSuggestion() {
const item = suggestionItem.value
if (!item) { ElMessage.warning('请选择当前实测 SKU'); return }
if (!Number.isInteger(warehouseSuggestion.packageCount) || warehouseSuggestion.packageCount < 1) { ElMessage.warning('箱数必须是大于 0 的整数'); return }
item.packageCount = warehouseSuggestion.packageCount
item.remark = warehouseSuggestion.remark.trim() || null
item.coefficientMode = warehouseSuggestion.coefficientMode
warehouseSuggestionOpen.value = false
ElMessage.success('仓库建议数据已加入,请保存测量数据')
}
async function loadChargeRules() {
if (!customerId.value || !access.can('firstleg:fba-pricing:rule:view')) return
chargeRulesLoading.value = true
try {
chargeRules.value = await listCustomerChargeRules(customerId.value)
} catch { chargeRules.value = [] }
finally { chargeRulesLoading.value = false }
}
function clearForm() {
form.measurementId = null
form.version = 0
@@ -261,6 +308,11 @@ async function loadMeasurement() {
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
if (sequence !== requestSequence) return
applyDetail(resource)
void loadChargeRules()
if (props.openWarehouseSuggestion && !warehouseSuggestionAutoOpened.value && form.items.length) {
warehouseSuggestionAutoOpened.value = true
openWarehouseSuggestion()
}
} catch (error) {
if (sequence !== requestSequence) return
detail.value = null
@@ -304,6 +356,14 @@ function validateDraft() {
else if (value > 999_999_999.999) itemErrors.push(`${label}超出系统允许上限 999,999,999.999 cm`)
else if (Math.abs(value * 1000 - Math.round(value * 1000)) > 1e-6) itemErrors.push(`${label}最多保留 3 位小数`)
}
const outerDimensions = [item.outerLengthCm, item.outerWidthCm, item.outerHeightCm]
if (outerDimensions.some((value) => value != null)) {
if (outerDimensions.some((value) => value == null)) itemErrors.push('仓库修改外箱长、宽、高必须同时填写')
outerDimensions.forEach((value) => {
if (value != null && (!Number.isFinite(value) || value <= 0)) itemErrors.push('仓库修改外箱尺寸必须大于 0')
else if (value != null && Math.abs(value * 1000 - Math.round(value * 1000)) > 1e-6) itemErrors.push('仓库修改外箱尺寸最多保留 3 位小数')
})
}
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
})
if (general.length) errors.general = general
@@ -348,7 +408,16 @@ async function saveDraft() {
conflictDetected.value = false
try {
const originalCounts = new Map((detail.value?.items || []).map((item) => [String(item.id), item.packageCount]))
const draftChanged = Boolean(detail.value && (detail.value.actualGoodsName || '') !== form.actualGoodsName)
const originalDraftValues = new Map((detail.value?.items || []).map((item) => [String(item.id), JSON.stringify({
lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.chargeRuleId || null, coefficientMode: item.coefficientMode || 'ACTUAL', remark: item.remark || null,
})]))
const draftChanged = Boolean(detail.value && ((detail.value.actualGoodsName || '') !== form.actualGoodsName || form.items.some((item) => originalDraftValues.get(item.itemId) !== JSON.stringify({
lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.chargeRuleId || null, coefficientMode: item.coefficientMode, remark: item.remark || null,
}))))
const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount)
let version = form.version
if (draftChanged) {
@@ -356,7 +425,14 @@ async function saveDraft() {
measurementId: form.measurementId,
version,
actualGoodsName: form.actualGoodsName.trim() || null,
items: form.items.map(({ itemId: _itemId, clientKey: _clientKey, serverValues: _serverValues, ...item }) => item),
items: form.items.map((item) => ({
inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
packageCount: item.packageCount, lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.coefficientMode === 'MULTIPLY' ? item.chargeRuleId : null,
coefficientMode: item.coefficientMode === 'ACTUAL' ? null : item.coefficientMode, remark: item.remark,
})),
})
version = saved.version
}
@@ -461,6 +537,17 @@ function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
}
function chargeRuleName(ruleId: string | null | undefined) {
if (!ruleId) return '默认规则'
const rule = chargeRules.value.find((item) => item.id === ruleId)
return rule ? chargeRuleLabel(rule) : ruleId
}
function chargeRuleLabel(rule: CustomerChargeRule) {
const remark = rule.remark?.trim()
return `系数 ${rule.lengthFactor}${remark ? ` · ${remark}` : ''}${rule.defaultRule ? '(默认)' : ''}`
}
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
@@ -470,6 +557,7 @@ watch(
operationError.value = null
conflictDetected.value = false
validationErrors.value = {}
warehouseSuggestionAutoOpened.value = false
clearForm()
if (hasContext.value && canView.value) void loadMeasurement()
},
@@ -567,7 +655,10 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>箱规明细</h3></div>
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
<div class="measurement-section-actions">
<el-button v-if="canWarehouseSuggestion" type="primary" plain :disabled="!mayMaintain || !form.items.length" @click="openWarehouseSuggestion"><Plus :size="15" />添加仓库建议数据</el-button>
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
</div>
</div>
<el-table
@@ -591,13 +682,22 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</dl>
</template>
</el-table-column>
<el-table-column type="index" label="#" width="48" align="center" />
<el-table-column prop="itemNo" label="#" width="70" align="center" />
<el-table-column label="箱号" min-width="156" show-overflow-tooltip>
<template #default="{ row }"><span class="measurement-box-code">{{ row.boxCode || '—' }}</span></template>
</el-table-column>
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
<el-table-column label="仓库修改外箱(长)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
<el-table-column label="仓库修改外箱(宽)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerWidthCm" aria-label="仓库修改外箱宽" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
<el-table-column label="仓库修改外箱(高)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerHeightCm" aria-label="仓库修改外箱高" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
<el-table-column label="确认箱数" width="132" align="center">
<template #default="{ row }">
<el-input-number
@@ -612,6 +712,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
/>
</template>
</el-table-column>
<el-table-column label="计算方式" min-width="150">
<template #default="{ row }">
<el-select v-model="row.coefficientMode" placeholder="选择计算方式">
<el-option label="实测原尺寸(不调整)" value="ACTUAL" />
<el-option label="按箱规尺寸策略" value="ADD" />
<el-option label="按箱规尺寸系数" value="MULTIPLY" :disabled="!chargeRules.some((rule) => rule.defaultRule && rule.status === 'ACTIVE')" />
</el-select>
</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="108" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
</el-table-column>
@@ -626,6 +735,18 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</el-table-column>
</el-table>
<el-empty v-else description="暂无扫描箱测量数据" />
<div v-if="form.items.length" class="warehouse-suggestion-table">
<h3>仓库建议数据</h3>
<el-table :data="form.items" border stripe empty-text="暂无仓库建议数据">
<el-table-column prop="itemNo" label="当前实测 SKU" min-width="150" />
<el-table-column label="外箱规 长×宽×高(cm)" min-width="190"><template #default="{ row }">{{ formatNumber(row.outerLengthCm) }} × {{ formatNumber(row.outerWidthCm) }} × {{ formatNumber(row.outerHeightCm) }}</template></el-table-column>
<el-table-column prop="packageCount" label="箱数" width="90" />
<el-table-column label="周长(cm)" width="110"><template #default="{ row }">{{ formatNumber(row.serverValues.girthCm) }}</template></el-table-column>
<el-table-column label="总体积(m³)" width="120"><template #default="{ row }">{{ formatNumber(row.serverValues.unitVolumeCbm == null ? null : row.serverValues.unitVolumeCbm * row.packageCount, 6) }}</template></el-table-column>
<el-table-column label="计算策略" min-width="190"><template #default="{ row }">{{ row.coefficientMode === 'MULTIPLY' ? '按箱规尺寸系数' : '按箱规尺寸策略' }}<span v-if="row.coefficientMode === 'MULTIPLY'">({{ chargeRuleName(row.chargeRuleId) }})</span></template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
</el-table>
</div>
</section>
</fieldset>
</form>
@@ -639,6 +760,21 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</div>
</footer>
<AppDialog v-model="warehouseSuggestionOpen" title="添加仓库建议数据" width="min(680px, calc(100vw - 28px))" confirm-text="加入建议" @confirm="applyWarehouseSuggestion">
<el-form label-position="top" class="warehouse-suggestion-form">
<el-form-item label="当前实测 SKU" required><el-select v-model="warehouseSuggestion.itemId" filterable><el-option v-for="item in form.items" :key="item.itemId" :label="`${item.itemNo} · ${item.boxSpecification || item.boxCode || '实测SKU'}`" :value="item.itemId" /></el-select></el-form-item>
<el-form-item label="箱数" required><el-input-number v-model="warehouseSuggestion.packageCount" :min="1" :max="1000000" step-strictly /></el-form-item>
<el-form-item label="计算方式"><el-select v-model="warehouseSuggestion.coefficientMode"><el-option label="按箱规尺寸策略" value="ADD" /><el-option label="按箱规尺寸系数" value="MULTIPLY" /></el-select></el-form-item>
<el-form-item label="备注"><el-input v-model="warehouseSuggestion.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<dl v-if="suggestionItem" class="suggestion-preview">
<div><dt>长 × 宽 × 高</dt><dd>{{ formatNumber(suggestionItem.lengthCm) }} × {{ formatNumber(suggestionItem.widthCm) }} × {{ formatNumber(suggestionItem.heightCm) }} cm</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(suggestionGirth) }} cm</dd></div>
<div><dt>单箱体积</dt><dd>{{ formatNumber(suggestionItem.serverValues.unitVolumeCbm, 6) }} m³</dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(suggestionTotalVolume, 6) }} m³</dd></div>
</dl>
</AppDialog>
<el-dialog v-model="imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview">
<div class="measurement-image-preview">
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
@@ -717,6 +853,8 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
.measurement-declaration h4 { margin: 0 0 10px; color: #344054; font-size: 12px; font-weight: 700; }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.measurement-section__heading > div { min-width: 0; }
.measurement-section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }.measurement-section-actions :deep(.el-button) { margin-left: 0; }
.warehouse-suggestion-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.warehouse-suggestion-form :deep(.el-select),.warehouse-suggestion-form :deep(.el-input-number) { width: 100%; }.warehouse-suggestion-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.suggestion-preview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 4px 0 0; border: 1px solid var(--cc-border); border-radius: 6px; }.suggestion-preview div { padding: 12px; border-right: 1px solid var(--cc-border); }.suggestion-preview div:last-child { border-right: 0; }.suggestion-preview dt { color: var(--cc-muted); font-size: 11px; }.suggestion-preview dd { margin: 5px 0 0; color: var(--cc-text); font-size: 13px; font-weight: 650; }
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
@@ -738,6 +876,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
.measurement-table :deep(.el-table__cell) { padding: 7px 0; }
.measurement-table :deep(.cell) { line-height: 1.35; }
.measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); }
.warehouse-suggestion-table { display: grid; gap: 10px; margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
.measurement-box-code { font-weight: 650; }
.measurement-count-editor { width: 104px !important; }
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 20px; margin: 0; padding: 4px 18px 8px 62px; }
+10 -4
View File
@@ -13,6 +13,7 @@ export type InboundMeasurementProcessStatus =
export interface InboundMeasurementItem {
id: MeasurementIdentifier
inboundDetailId?: MeasurementIdentifier
boxCode: string | null
itemNo: string
boxSpecification: string
@@ -33,6 +34,8 @@ export interface InboundMeasurementItem {
outerLengthCm: number | null
outerWidthCm: number | null
outerHeightCm: number | null
chargeRuleId?: MeasurementIdentifier | null
coefficientMode?: 'ADD' | 'MULTIPLY' | null
remark: string | null
}
@@ -93,21 +96,24 @@ export interface InboundMeasurementDetail {
}
export interface SaveInboundMeasurementItemRequest {
boxCode: string | null
inboundDetailId?: MeasurementIdentifier
boxCode?: string | null
itemNo: string
boxSpecification: string
packageCount: number
lengthCm: number
widthCm: number
heightCm: number
unitWeightKg: number
unitWeightKg?: number
intelligentImageFileId: MeasurementIdentifier | null
machineNo: string | null
measuredAt: string | null
machineNo?: string | null
measuredAt?: string | null
overLimitWarning: string | null
outerLengthCm: number | null
outerWidthCm: number | null
outerHeightCm: number | null
chargeRuleId?: MeasurementIdentifier | null
coefficientMode?: 'ADD' | 'MULTIPLY' | null
remark: string | null
}
@@ -297,6 +297,14 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('<ChevronUp v-if="!measurementCollapsed.customer"')
expect(source).toContain('<ChevronDown v-else :size="18" />')
expect(source).toContain("{ key: 'girthCm', label: '箱规周长(cm)' }")
expect(source).toContain('label="#" prop="itemNo"')
expect(source).toContain("const actualMeasuredColumns = measuredColumns.filter((column) => column.key !== 'itemNo')")
expect(source).toContain('v-for="column in actualMeasuredColumns"')
expect(source).toContain('仓库修改外箱(长)')
expect(source).toContain('仓库修改外箱(宽)')
expect(source).toContain('仓库修改外箱(高)')
expect(source).toContain('saveWarehouseOuterDimensions')
expect(source).toContain('相关数值已重新计算')
expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)')
expect(source).toContain(':aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain(':title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
@@ -313,8 +321,37 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('formatNumber(2 * (width + height))')
})
it('shows the matched dimension addition and enabled multiplier in calculation choices', () => {
expect(source).toContain('getCustomerDimensionConfig')
expect(source).toContain('matchingDimensionRule')
expect(source).toContain('`原尺寸 + ${values[0]}cm`')
expect(source).toContain('`原尺寸 × ${rule.lengthFactor}`')
expect(source).toContain('label="箱规尺寸" value="ADD"')
expect(source).toContain('label="箱规系数" value="MULTIPLY"')
})
it('keeps the three section calculation modes independent and recalculates their display rows', () => {
expect(source).toContain("measurementSectionModes = reactive<Record<MeasurementSection, MeasurementCalculationMode>>")
expect(source).toContain(':model-value="measurementSectionModes.customer"')
expect(source).toContain(':model-value="measurementSectionModes.outer"')
expect(source).toContain(':model-value="measurementSectionModes.actual"')
expect(source).toContain("updateMeasurementSectionMode('customer', $event)")
expect(source).toContain("updateMeasurementSectionMode('outer', $event)")
expect(source).toContain("updateMeasurementSectionMode('actual', $event)")
expect(source).toContain('calculatedMeasurementRow(row, measurementSectionModes.customer, true)')
expect(source).toContain('}, measurementSectionModes.outer)')
expect(source).toContain('calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual)')
expect(source).toContain('rawDimensions.map((value) => value + rule.edgeAdditionCm)')
expect(source).toContain('matchedSpecificationCoefficient(chargeRule, calculationSource)')
expect(source).toContain('listCustomerChargeRules(customerId, tenantId)')
expect(source).toContain('matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))')
expect(source).not.toContain('[chargeRule.lengthFactor, chargeRule.widthFactor, chargeRule.heightFactor]')
expect(source).not.toContain('column.key === \'coefficientMode\'')
expect(source).not.toContain('@change="updateMeasurementCalculationMode(null, $event)"')
})
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
expect(source).toContain('<el-table :data="measurementDetail?.items || []" row-key="id"')
expect(source).toContain('<el-table :data="actualMeasurementRows" row-key="id"')
expect(source).toContain('<el-table-column label="箱号" prop="boxCode"')
expect(source).toContain("row.boxCode || '—'")
expect(source).toContain("`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`")
@@ -444,9 +481,10 @@ describe('UnifiedOrdersView contract', () => {
it('matches the prototype table grid and distinguishes container identifiers', () => {
expect(source).toContain('row-key="orderId" border stripe')
expect(source).toContain('<el-table :data="detail.order.cargoes" border stripe')
expect(source.match(/<el-table :data="measurementDetail\?\.items \|\| \[\]"[^>]*border stripe/g)).toHaveLength(2)
expect(source).toContain('<el-table :data="detail.order.cargoes" border stripe empty-text="暂无客户下单数据"')
expect(source).toContain('<el-table :data="customerMeasurementRows" border stripe')
expect(source).toContain('<el-table :data="outerMeasurementRows" border stripe')
expect(source).toContain('<el-table :data="actualMeasurementRows" row-key="id" border stripe')
expect(source).toContain('<el-table :data="customerMeasurementRows" border stripe empty-text="暂无客户下单数据"')
expect(source).toContain('--el-table-border-color: #e5e7eb')
expect(source).toContain('--el-table-row-hover-bg-color: #f3f7fb')
expect(source).toContain('.el-table--striped .el-table__body tr.el-table__row--striped')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, Send, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -57,12 +57,20 @@ import {
} from '@/modules/unified-orders/status'
import {
downloadWeightImage,
calculateInboundFee,
confirmInboundFee,
createFeeActionIdempotencyKey,
getInboundMeasurement,
saveInboundMeasurementDraft,
getLatestInboundFee,
InboundFeePanel,
InboundMeasurementDialog,
MeasurementEvidenceThumbnail,
sendInboundFee,
type InboundMeasurementDetail,
} from '@/modules/inbounds'
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
import { getCustomerDimensionConfig, type CustomerDimensionConfig, type CustomerDimensionRule } from '@/modules/dimension-config'
import {
OrderBasicInfoIcon,
OrderCargoInfoIcon,
@@ -151,14 +159,44 @@ const measurementDetail = ref<InboundMeasurementDetail | null>(null)
const measurementLoading = ref(false)
const measurementError = ref<ApiErrorDetails | null>(null)
const measurementLoadedKey = ref('')
const measurementChargeRules = ref<CustomerChargeRule[]>([])
const measurementChargeRulesLoading = ref(false)
const measurementChargeRulesLoadedKey = ref('')
const measurementDimensionConfig = ref<CustomerDimensionConfig | null>(null)
const measurementDimensionConfigLoading = ref(false)
const measurementDimensionConfigLoadedKey = ref('')
const measurementStrategySavingId = ref('')
const measurementOuterSavingId = ref('')
const measurementPreviewOpen = ref(false)
const measurementPreviewLoading = ref(false)
const measurementPreviewError = ref<ApiErrorDetails | null>(null)
const measurementPreviewUrl = ref('')
const measurementPreviewTitle = ref('测量图片')
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
type MeasurementSection = keyof typeof measurementCollapsed
type MeasurementCalculationMode = 'ACTUAL' | 'ADD' | 'MULTIPLY'
const measurementSectionModes = reactive<Record<MeasurementSection, MeasurementCalculationMode>>({
customer: 'ACTUAL', outer: 'ACTUAL', actual: 'ACTUAL',
})
const cargoImagePreviewUrls = new Set<string>()
const measurementDialogOpen = ref(false)
const openWarehouseSuggestionOnMeasurementLoad = ref(false)
const confirmFeeSending = ref(false)
const feeConfirmOpen = ref(false)
const feeConfirmWeight = ref<number | null>(null)
const feeConfirmVolume = ref<number | null>(null)
const feeConfirmRows = computed(() => {
const summary = detail.value?.summary
const measurement = summary?.measurement
const actualWeight = Number(measurement?.actualGrossWeightKg)
const actualVolume = Number(measurement?.actualVolumeCbm)
const customerTotals = measurementTotals(customerMeasurementRows.value)
return [
{ source: '客户下单数据', count: summary?.totalPackageCount, 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 },
]
})
const smartLoadingOpen = ref(false)
const measurementOrder = ref<UnifiedOrderResource | null>(null)
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
@@ -213,8 +251,140 @@ const customerMeasurementColumns = [
]
const measuredColumns = [
...customerMeasurementColumns.slice(0, 7), { key: 'girthCm', label: '箱规周长(cm)' }, { key: 'totalWeightKg', label: '总毛重(T.K.GW)' },
{ key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' }, { key: 'remark', label: '备注' },
{ key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' },
{ key: 'remark', label: '备注' },
]
const outerColumns = measuredColumns
const actualMeasuredColumns = measuredColumns.filter((column) => column.key !== 'itemNo')
function sortedDimensions(row: Record<string, unknown>) {
const values = [Number(row.lengthCm), Number(row.widthCm), Number(row.heightCm)]
return values.every((value) => Number.isFinite(value) && value > 0)
? values.sort((left, right) => right - left)
: null
}
function matchingDimensionRule(row: Record<string, unknown>): CustomerDimensionRule | null {
const measured = sortedDimensions(row)
if (!measured) return null
return [...(measurementDimensionConfig.value?.rules || [])]
.sort((left, right) => (
left.innerLengthCm * left.innerWidthCm * left.innerHeightCm
- right.innerLengthCm * right.innerWidthCm * right.innerHeightCm
))
.find((rule) => {
const inner = [rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm].sort((left, right) => right - left)
return measured.every((value, index) => value <= inner[index])
}) || null
}
function addCoefficientLabel(row?: Record<string, unknown>) {
const rows = row ? [row] : (measurementDetail.value?.items || []) as unknown as Record<string, unknown>[]
const values = [...new Set(rows.map((item) => matchingDimensionRule(item)?.edgeAdditionCm).filter((value) => value != null))]
if (values.length === 1) return `原尺寸 + ${values[0]}cm`
if (measurementDimensionConfigLoading.value) return '原尺寸 + 加载中…'
return values.length > 1 ? '原尺寸 + 按箱规匹配' : '原尺寸 + 未匹配箱规'
}
function multiplyCoefficientLabel() {
const rule = measurementChargeRules.value.find((item) => item.defaultRule && item.status === 'ACTIVE')
if (rule) return `原尺寸 × ${rule.lengthFactor}`
return measurementChargeRulesLoading.value ? '原尺寸 × 加载中…' : '原尺寸 × 未启用系数'
}
function matchedSpecificationCoefficient(rule: CustomerChargeRule, row: Record<string, unknown>) {
const tiers = [...(rule.remark || '').matchAll(
/(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+(?:\.\d+)?);系数:(\d+(?:\.\d+)?)/g,
)].map((match) => ({
dimensions: [Number(match[2]), Number(match[3]), Number(match[4])].sort((left, right) => right - left),
coefficient: Number(match[5]),
})).filter((tier) => tier.dimensions.every((value) => Number.isFinite(value) && value > 0)
&& Number.isFinite(tier.coefficient) && tier.coefficient > 0)
.sort((left, right) => left.dimensions.reduce((total, value) => total * value, 1)
- right.dimensions.reduce((total, value) => total * value, 1))
if (!tiers.length) return Number(rule.lengthFactor || 1)
const measured = [Number(row.lengthCm), Number(row.widthCm), Number(row.heightCm)]
.sort((left, right) => right - left)
const matched = tiers.find((tier) => measured.every((value, index) => value <= tier.dimensions[index]))
return (matched || tiers[tiers.length - 1]).coefficient
}
const activeMeasurementChargeRule = computed(() => measurementChargeRules.value
.find((rule) => rule.defaultRule && rule.status === 'ACTIVE') || null)
const multiplyModeDisabled = computed(() => !measurementChargeRulesLoading.value && !activeMeasurementChargeRule.value)
function roundedUp(value: number, digits: number) {
const factor = 10 ** digits
return Math.ceil((value - Number.EPSILON) * factor) / factor
}
function calculatedMeasurementRow(
source: Record<string, unknown>,
mode: MeasurementCalculationMode,
customerSource = false,
) {
const calculationSource = !customerSource && mode !== 'ACTUAL'
? {
...source,
lengthCm: source.outerLengthCm ?? source.lengthCm,
widthCm: source.outerWidthCm ?? source.widthCm,
heightCm: source.outerHeightCm ?? source.heightCm,
}
: source
const rule = matchingDimensionRule(calculationSource)
const rawDimensions = [Number(calculationSource.lengthCm), Number(calculationSource.widthCm), Number(calculationSource.heightCm)]
if (!rawDimensions.every((value) => Number.isFinite(value) && value > 0)) return { ...source }
const matchedDimensions = mode === 'ADD' && rule
? rawDimensions.map((value) => value + rule.edgeAdditionCm)
: rawDimensions
const chargeRule = activeMeasurementChargeRule.value
const specificationCoefficient = mode === 'MULTIPLY' && chargeRule
? matchedSpecificationCoefficient(chargeRule, calculationSource)
: 1
const [lengthCm, widthCm, heightCm] = matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))
const packageCount = Number(source.packageCount)
const unitVolumeCbm = mode === 'ADD' && rule
? Math.round((lengthCm * widthCm * heightCm / 1_000_000) * 1000) / 1000
: roundedUp(lengthCm * widthCm * heightCm / 1_000_000, 4)
const totalVolumeCbm = Number.isFinite(packageCount) ? roundedUp(unitVolumeCbm * packageCount, 4) : null
const sourceUnitWeight = customerSource
? Number(source.grossWeightKg) / packageCount
: Number(source.unitWeightKg)
const unitWeightKg = Number.isFinite(sourceUnitWeight) ? sourceUnitWeight : null
const totalWeightKg = unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null
return {
...source,
lengthCm, widthCm, heightCm, unitWeightKg, unitVolumeCbm, totalWeightKg, totalVolumeCbm,
girthCm: roundedUp(lengthCm + 2 * (widthCm + heightCm), 3),
volumeWeightKg: totalVolumeCbm == null ? null : roundedUp(totalVolumeCbm * 167, 3),
remark: customerSource ? source.cargoRemark : source.remark,
}
}
const customerMeasurementRows = computed(() => (detail.value?.order.cargoes || []).map((row) => (
calculatedMeasurementRow(row, measurementSectionModes.customer, true)
)))
const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => {
const source = row as unknown as Record<string, unknown>
return calculatedMeasurementRow({
...source,
lengthCm: source.outerLengthCm ?? source.lengthCm,
widthCm: source.outerWidthCm ?? source.widthCm,
heightCm: source.outerHeightCm ?? source.heightCm,
}, measurementSectionModes.outer)
}))
const actualMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => (
calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual)
)))
function updateMeasurementSectionMode(section: MeasurementSection, mode: MeasurementCalculationMode) {
if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) {
ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则')
return
}
measurementSectionModes[section] = mode
}
const statusTabs = computed(() => [
{ value: '' as const, label: '全部' },
@@ -529,7 +699,12 @@ function feeReviewLabel(value: string | null | undefined) {
return unifiedOrderReviewStatusLabel(value)
}
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false) {
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false, outerSource = false) {
if (key === 'coefficientMode') return measurementCalculationLabel(row)
if (outerSource && ['lengthCm', 'widthCm', 'heightCm'].includes(key)) {
const outerValue = row[`outer${key[0].toUpperCase()}${key.slice(1)}`]
return outerValue == null ? '—' : formatNumber(Number(outerValue), 6)
}
if (customerSource && key === 'unitWeightKg') {
const total = Number(row.grossWeightKg)
const count = Number(row.packageCount)
@@ -546,6 +721,191 @@ function measurementValue(row: Record<string, unknown>, key: string, customerSou
return typeof value === 'number' ? formatNumber(value, 6) : String(value)
}
function measurementCalculationLabel(row: Record<string, unknown>) {
if (measurementCalculationModeForRow(row) === 'MULTIPLY') {
return `乘以${measurementChargeRuleName(row.chargeRuleId)}`
}
if (measurementCalculationModeForRow(row) === 'ACTUAL') return '实测原尺寸(不调整)'
return addCoefficientLabel(row).replace('原尺寸 + ', '加')
}
function measurementCalculationModeForRow(row: Record<string, unknown>): MeasurementCalculationMode {
if (row.coefficientMode === 'MULTIPLY') return 'MULTIPLY'
if (row.coefficientMode === 'ADD') return 'ADD'
const itemNo = normalizedIdentifier(row.itemNo)
const measuredItem = measurementDetail.value?.items.find((item) => normalizedIdentifier(item.itemNo) === itemNo)
return measuredItem?.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : measuredItem?.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL'
}
async function updateMeasurementCalculationMode(row: Record<string, unknown> | null, mode: Exclude<MeasurementCalculationMode, 'ACTUAL'>) {
const summary = detail.value?.summary
const measurement = measurementDetail.value
if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null || !can('platform:firstleg:inbound:measure:edit')) return
const itemId = normalizedIdentifier(row?.id)
const itemNo = normalizedIdentifier(row?.itemNo)
const targetIds = new Set(measurement.items
.filter((item) => !row || (itemId ? String(item.id) === itemId : normalizedIdentifier(item.itemNo) === itemNo))
.map((item) => String(item.id)))
if (!targetIds.size) {
ElMessage.warning('当前数据尚未关联实测明细,暂不能设置计算方式')
return
}
measurementStrategySavingId.value = row ? (itemId || `item-${itemNo}`) : 'all'
try {
await Promise.all([loadMeasurementChargeRules(), loadMeasurementDimensionConfig()])
const activeRule = measurementChargeRules.value.find((rule) => rule.defaultRule && rule.status === 'ACTIVE')
const requiresMultiply = measurement.items.some((item) => (
targetIds.has(String(item.id)) ? mode : item.coefficientMode
) === 'MULTIPLY')
if (requiresMultiply && !activeRule) {
ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则')
return
}
await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), {
measurementId: String(measurement.measurementId),
version: measurement.version,
actualGoodsName: measurement.actualGoodsName,
items: measurement.items.map((item) => ({
inboundDetailId: item.inboundDetailId,
boxCode: item.boxCode,
itemNo: item.itemNo,
boxSpecification: item.boxSpecification,
packageCount: item.packageCount,
lengthCm: item.lengthCm,
widthCm: item.widthCm,
heightCm: item.heightCm,
unitWeightKg: item.unitWeightKg,
intelligentImageFileId: item.intelligentImageFileId,
machineNo: item.machineNo,
measuredAt: item.deviceMeasuredAt,
overLimitWarning: item.overLimitWarning,
outerLengthCm: item.outerLengthCm,
outerWidthCm: item.outerWidthCm,
outerHeightCm: item.outerHeightCm,
chargeRuleId: (targetIds.has(String(item.id)) ? mode : item.coefficientMode) === 'MULTIPLY'
? activeRule!.id
: null,
coefficientMode: targetIds.has(String(item.id)) ? mode : item.coefficientMode || 'ADD',
remark: item.remark,
})),
})
measurementLoadedKey.value = ''
await loadMeasurementDetails()
ElMessage.success(`${row ? '当前数据' : '全部测量数据'}的计算方式已更新,体积已重新计算`)
} catch (error) {
measurementLoadedKey.value = ''
await loadMeasurementDetails()
ElMessage.error(getApiErrorDetails(error, '计算方式更新失败').message)
} finally {
measurementStrategySavingId.value = ''
}
}
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 currentStatus = editableMeasurementStatuses.has(measurement?.processStatus || '')
? measurement!.processStatus
: summary?.mainStatus || measurement?.processStatus || ''
if (measurement && !editableMeasurementStatuses.has(currentStatus)) {
const statusLabel = orderStatusLabel(summary?.mainStatus) || measurementStatusLabel(currentStatus)
ElMessage.warning(`当前状态为“${statusLabel}”,该状态不可以修改测量数据`)
return
}
if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null
|| !can('platform:firstleg:inbound:measure:edit')) return
const dimensions = [row.outerLengthCm, row.outerWidthCm, row.outerHeightCm].map(Number)
if (dimensions.some((value) => !Number.isFinite(value) || value <= 0)) {
ElMessage.warning('仓库修改外箱长、宽、高必须同时填写且大于 0')
return
}
measurementOuterSavingId.value = String(row.id)
try {
await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), {
measurementId: String(measurement.measurementId),
version: measurement.version,
actualGoodsName: measurement.actualGoodsName,
items: measurement.items.map((item) => ({
inboundDetailId: item.inboundDetailId,
boxCode: item.boxCode,
itemNo: item.itemNo,
boxSpecification: item.boxSpecification,
packageCount: item.packageCount,
lengthCm: item.lengthCm,
widthCm: item.widthCm,
heightCm: item.heightCm,
unitWeightKg: item.unitWeightKg,
intelligentImageFileId: item.intelligentImageFileId,
machineNo: item.machineNo,
measuredAt: item.deviceMeasuredAt,
overLimitWarning: item.overLimitWarning,
outerLengthCm: String(item.id) === String(row.id) ? dimensions[0] : item.outerLengthCm,
outerWidthCm: String(item.id) === String(row.id) ? dimensions[1] : item.outerWidthCm,
outerHeightCm: String(item.id) === String(row.id) ? dimensions[2] : item.outerHeightCm,
chargeRuleId: item.chargeRuleId,
coefficientMode: item.coefficientMode || 'ADD',
remark: item.remark,
})),
})
measurementLoadedKey.value = ''
await loadMeasurementDetails()
ElMessage.success(`序号 ${row.itemNo || '—'} 的外箱规已更新,相关数值已重新计算`)
} catch (error) {
measurementLoadedKey.value = ''
await loadMeasurementDetails()
ElMessage.error(getApiErrorDetails(error, '仓库外箱尺寸保存失败').message)
} finally {
measurementOuterSavingId.value = ''
}
}
function measurementChargeRuleName(ruleId: unknown) {
const normalizedRuleId = normalizedIdentifier(ruleId)
const selected = normalizedRuleId
? measurementChargeRules.value.find((rule) => rule.id === normalizedRuleId)
: null
const fallback = measurementChargeRules.value.find((rule) => rule.defaultRule && rule.status === 'ACTIVE')
|| measurementChargeRules.value.find((rule) => rule.status === 'ACTIVE')
const rule = selected || fallback
if (rule) {
const remark = rule.remark?.trim()
return `系数 ${rule.lengthFactor}${remark ? ` · ${remark}` : ''}${rule.defaultRule ? '(默认)' : ''}`
}
if (measurementChargeRulesLoading.value) return '加载中…'
return normalizedRuleId || '未配置'
}
async function loadMeasurementChargeRules() {
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
const customerId = normalizedIdentifier(detail.value?.summary.customerId) || tenantId
const loadedKey = `${tenantId}:${customerId}`
if (!customerId || measurementChargeRulesLoadedKey.value === loadedKey || measurementChargeRulesLoading.value) return
measurementChargeRulesLoading.value = true
try {
measurementChargeRules.value = await listCustomerChargeRules(customerId, tenantId)
measurementChargeRulesLoadedKey.value = loadedKey
} catch {
measurementChargeRules.value = []
} finally {
measurementChargeRulesLoading.value = false
}
}
async function loadMeasurementDimensionConfig() {
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
if (!tenantId || measurementDimensionConfigLoadedKey.value === tenantId || measurementDimensionConfigLoading.value) return
measurementDimensionConfigLoading.value = true
try {
measurementDimensionConfig.value = await getCustomerDimensionConfig(tenantId)
measurementDimensionConfigLoadedKey.value = tenantId
} catch {
measurementDimensionConfig.value = null
} finally {
measurementDimensionConfigLoading.value = false
}
}
interface MeasurementTotals {
count: number
gross: number
@@ -608,8 +968,16 @@ async function loadMeasurementDetails() {
measurementLoading.value = true
measurementError.value = null
try {
const response = await getInboundMeasurement(String(summary.tenantId), String(summary.inboundId))
const [response] = await Promise.all([
getInboundMeasurement(String(summary.tenantId), String(summary.inboundId)),
loadMeasurementChargeRules(),
loadMeasurementDimensionConfig(),
])
if (serial !== measurementRequestSerial) return
const configuredMode = measurementDimensionConfig.value?.defaultCalculationMode
measurementSectionModes.outer = configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value
? 'MULTIPLY'
: 'ADD'
measurementDetail.value = response
measurementLoadedKey.value = key
} catch (error) {
@@ -837,6 +1205,64 @@ function openMeasurement(row: UnifiedOrderResource) {
measurementDialogOpen.value = true
}
function openFeeConfirmDialog() {
const summary = detail.value?.summary
const reviewStatus = String(summary?.measurement?.reviewStatus ?? '').trim().toUpperCase()
if (reviewStatus !== 'APPROVED') {
ElMessage.warning('测量审核通过后才能确认并发送费用')
return
}
if (!summary?.inboundId) {
ElMessage.warning('当前单据缺少入仓信息,暂不能发送费用')
return
}
if (confirmFeeSending.value) {
return
}
feeConfirmWeight.value = Number(summary.measurement?.actualGrossWeightKg) || null
feeConfirmVolume.value = Number(summary.measurement?.actualVolumeCbm) || null
feeConfirmOpen.value = true
}
async function executeConfirmAndSendFee() {
const summary = detail.value?.summary
if (!summary?.inboundId) return
const selectedWeightKg = Number(feeConfirmWeight.value)
const selectedVolumeCbm = Number(feeConfirmVolume.value)
if (!(selectedWeightKg > 0) || !(selectedVolumeCbm > 0)) {
ElMessage.warning('计费毛重和计费体积必须大于 0')
return
}
confirmFeeSending.value = true
try {
const tenantId = String(summary.tenantId)
const inboundId = String(summary.inboundId)
let snapshot = await getLatestInboundFee(tenantId, inboundId)
if (snapshot.feeStatus === 'CALCULATED') {
snapshot = await calculateInboundFee(tenantId, inboundId, {
selectedWeightKg,
selectedVolumeCbm,
weightSource: 'ACTUAL',
volumeSource: 'ACTUAL',
}, createFeeActionIdempotencyKey('calculate'))
snapshot = await confirmInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('confirm'))
}
if (snapshot.feeStatus === 'CONFIRMED') {
await sendInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('send'))
ElMessage.success('费用已确认并发送给客户')
feeConfirmOpen.value = false
await loadDetail(true)
return
}
if (snapshot.feeStatus === 'SENT') ElMessage.info('该费用已经发送给客户,无需重复发送')
else ElMessage.warning('当前没有可发送的试算费用,请先在费用信息中完成费用试算')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)
} finally {
confirmFeeSending.value = false
}
}
function handleOrderSelectionChange(rows: UnifiedOrderResource[]) {
selectedOrders.value = rows
selectedApprovalOrders.value = rows.filter(canApprove)
@@ -1021,6 +1447,7 @@ function handleMeasurementSubmitted() {
function clearMeasurementOrder() {
measurementOrder.value = null
openWarehouseSuggestionOnMeasurementLoad.value = false
}
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
@@ -1163,6 +1590,17 @@ async function loadDetail(force = false) {
measurementDetail.value = null
measurementError.value = null
measurementLoadedKey.value = ''
measurementSectionModes.customer = 'ACTUAL'
measurementSectionModes.outer = 'ACTUAL'
measurementSectionModes.actual = 'ACTUAL'
if (measurementChargeRulesLoadedKey.value !== tenantId) {
measurementChargeRules.value = []
measurementChargeRulesLoadedKey.value = ''
}
if (measurementDimensionConfigLoadedKey.value !== tenantId) {
measurementDimensionConfig.value = null
measurementDimensionConfigLoadedKey.value = ''
}
measurementPreviewOpen.value = false
releaseMeasurementPreview()
releaseCargoImagePreviews()
@@ -1226,6 +1664,22 @@ onBeforeUnmount(() => {
<template>
<AdminPage>
<section class="business-page unified-orders-page">
<AppDialog v-model="feeConfirmOpen" title="编辑计费数据" confirm-text="确认" :confirm-loading="confirmFeeSending" width="min(920px, 94vw)" :teleported="false" @confirm="executeConfirmAndSendFee">
<el-form label-position="top" class="fee-confirm-form">
<el-form-item label="入仓码"><el-input :model-value="detail?.summary.inboundCode || '—'" disabled /></el-form-item>
<el-form-item label="渠道"><el-input :model-value="detail?.summary.channelName || detail?.summary.channelCode || '—'" disabled /></el-form-item>
<el-form-item label="计费毛重"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" class="fee-confirm-number" /><span class="fee-confirm-unit">kg</span></div></el-form-item>
<el-form-item label="计费体积"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" class="fee-confirm-number" /><span class="fee-confirm-unit">m³</span></div></el-form-item>
</el-form>
<h4 class="fee-confirm-reference-title">参考数据</h4>
<el-table :data="feeConfirmRows" border size="small">
<el-table-column prop="source" label="数据来源" />
<el-table-column prop="count" label="件数" />
<el-table-column label="总毛重(T.K.GW)"><template #default="{ row }">{{ row.weight == null ? '—' : formatNumber(row.weight, 2) }}</template></el-table-column>
<el-table-column label="总体积(m³)"><template #default="{ row }">{{ row.volume == null ? '—' : formatNumber(row.volume, 4) }}</template></el-table-column>
<el-table-column label="总材积重"><template #default="{ row }">{{ row.volume == null ? '—' : formatNumber(Number(row.volume) * 167, 2) }}</template></el-table-column>
</el-table>
</AppDialog>
<template v-if="isDetailRoute">
<DetailPageLayout ariaLabel="统一订单详情">
<header class="order-detail-header">
@@ -1420,7 +1874,19 @@ onBeforeUnmount(() => {
/>
</template>
<template v-else-if="activeDetailTab === 'measurement'">
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
<div class="panel-title">
<div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div>
<div class="measurement-title-actions">
<el-button
v-if="can('firstleg:inbound:confirm-send-fee')"
type="primary"
:loading="confirmFeeSending"
:title="detail.summary.measurement?.reviewStatus === 'APPROVED' ? '确认并发送费用' : '测量审核通过后才能发送费用'"
data-testid="confirm-send-fee-button"
@click="openFeeConfirmDialog"
><Send :size="15" />确认费用发送</el-button>
</div>
</div>
<el-alert v-if="!can('platform:firstleg:inbound:measure:view')" title="当前账号缺少测量查看权限" type="warning" :closable="false" show-icon />
<LoadErrorState v-else-if="measurementError" title="测量数据暂时无法加载" :message="measurementError.message" :loading="measurementLoading" @retry="loadMeasurementDetails">
<template #details><ApiErrorMeta :details="measurementError" /></template>
@@ -1428,28 +1894,50 @@ onBeforeUnmount(() => {
</LoadErrorState>
<template v-else>
<section class="measurement-section">
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button>
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(customerMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.customer" aria-label="客户下单数据计算方式" @change="updateMeasurementSectionMode('customer', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
<div v-if="!measurementCollapsed.customer">
<div class="detail-table-wrap"><el-table :data="detail.order.cargoes" border stripe empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key, true)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key, true) }}</template></template></el-table-column></el-table></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span></div>
<div class="detail-table-wrap"><el-table :data="customerMeasurementRows" border stripe empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span></div>
</div>
</section>
<section class="measurement-section">
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button>
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(outerMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.outer" aria-label="外箱规数据计算方式" @change="updateMeasurementSectionMode('outer', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
<div v-if="!measurementCollapsed.outer">
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" border stripe empty-text="暂无外箱规数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span></div>
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="outerMeasurementRows" border stripe empty-text="暂无外箱规数据"><el-table-column label="#" prop="itemNo" min-width="70" /><el-table-column v-for="column in outerColumns.filter((item) => item.key !== 'itemNo')" :key="column.key" :label="column.label" :prop="column.key" min-width="150"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span></div>
</div>
</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="外箱规数据计算方式" @change="updateMeasurementSectionMode('outer', $event)">
<el-option label="实测原尺寸(不调整)" value="ACTUAL" />
<el-option label="箱规尺寸" value="ADD" />
<el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" />
</el-select>
<span class="measurement-rule-selector__hint">规则参数来源:客户管理</span>
</div>
<section class="measurement-section">
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积重:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span><span>重量体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button>
<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 v-if="!measurementCollapsed.actual">
<div v-loading="measurementLoading" class="detail-table-wrap">
<el-table :data="measurementDetail?.items || []" row-key="id" border stripe empty-text="暂无实际测量数据">
<el-table :data="actualMeasurementRows" row-key="id" border stripe empty-text="暂无实际测量数据">
<el-table-column label="箱号" prop="boxCode" min-width="190" fixed="left" show-overflow-tooltip>
<template #default="{ row }"><strong class="measurement-box-code">{{ row.boxCode || '—' }}</strong></template>
</el-table-column>
<el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120">
<el-table-column label="#" prop="itemNo" min-width="70" fixed="left" />
<el-table-column label="仓库修改外箱(长)" min-width="160">
<template #default="{ row }"><el-input-number v-if="can('platform:firstleg:inbound:measure:edit')" v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="请输入" /><template v-else>{{ formatNumber(row.outerLengthCm) }}</template></template>
</el-table-column>
<el-table-column label="仓库修改外箱(宽)" min-width="160">
<template #default="{ row }"><el-input-number v-if="can('platform:firstleg:inbound:measure:edit')" v-model="row.outerWidthCm" aria-label="仓库修改外箱宽" :min="0.001" :precision="3" :controls="false" placeholder="请输入" /><template v-else>{{ formatNumber(row.outerWidthCm) }}</template></template>
</el-table-column>
<el-table-column label="仓库修改外箱(高)" min-width="160">
<template #default="{ row }"><el-input-number v-if="can('platform:firstleg:inbound:measure:edit')" v-model="row.outerHeightCm" aria-label="仓库修改外箱高" :min="0.001" :precision="3" :controls="false" placeholder="请输入" /><template v-else>{{ formatNumber(row.outerHeightCm) }}</template></template>
</el-table-column>
<el-table-column v-if="can('platform:firstleg:inbound:measure:edit')" label="仓库外箱操作" min-width="120">
<template #default="{ row }"><el-button type="primary" plain size="small" :loading="measurementOuterSavingId === String(row.id)" :disabled="Boolean(measurementOuterSavingId)" @click="saveWarehouseOuterDimensions(row as Record<string, unknown>)">保存外箱</el-button></template>
</el-table-column>
<el-table-column v-for="column in actualMeasuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120">
<template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template>
</el-table-column>
<el-table-column label="尺寸图片" min-width="148">
@@ -1466,7 +1954,7 @@ onBeforeUnmount(() => {
</el-table-column>
</el-table>
</div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span></div>
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
<div class="weight-evidence__heading">
<div><h4>称重图片与确认重量</h4><p>称重图片独立展示;重量值直接采用服务端确认结果。</p></div>
@@ -1670,6 +2158,7 @@ onBeforeUnmount(() => {
:customer-id="String(measurementOrder.tenantId)"
:inbound-id="String(measurementOrder.inboundId)"
:inbound-code="measurementOrder.inboundCode"
:open-warehouse-suggestion="openWarehouseSuggestionOnMeasurementLoad"
@updated="handleMeasurementUpdated"
@submitted="handleMeasurementSubmitted"
@closed="clearMeasurementOrder"
@@ -1765,6 +2254,15 @@ onBeforeUnmount(() => {
.panel-title h2 { margin: 0; color: #344054; font-size: 16px; }
.panel-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; }
.panel-title > svg { color: var(--cc-primary); }
.measurement-title-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.measurement-title-actions :deep(.el-button) { margin-left: 0; }
.fee-confirm-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
.fee-confirm-number-wrap { position: relative; width: 100%; }
.fee-confirm-number { width: 100%; }
.fee-confirm-number :deep(.el-input__wrapper) { padding-right: 42px; }
.fee-confirm-unit { position: absolute; top: 50%; right: 12px; z-index: 1; transform: translateY(-50%); color: var(--cc-muted); pointer-events: none; }
.fee-confirm-reference-title { margin: 8px 0 12px; color: var(--cc-text); font-size: 14px; }
@media (max-width: 680px) { .fee-confirm-form { grid-template-columns: 1fr; } .fee-confirm-unit { right: 12px; } }
.fee-section-title { margin: 14px 0 8px; color: #344054; font-size: 13px; line-height: 1.4; }
.fee-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 8px 0 0; border: 1px solid var(--cc-border); }
.fee-overview > div { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 4px 12px; padding: 10px 12px; }
@@ -1783,13 +2281,19 @@ onBeforeUnmount(() => {
.cargo-image { width: 72px; height: 56px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
.measurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); background: var(--cc-border); }
.measurement-section { margin-top: 18px; }
.measurement-section-title { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 16px; padding: 9px 14px; border: 1px solid var(--cc-border); border-radius: 7px 7px 0 0; background: #f8fafc; color: var(--cc-text); font: inherit; text-align: left; cursor: pointer; }
.measurement-section-title { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 12px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 7px 7px 0 0; background: #f8fafc; color: var(--cc-text); }
.measurement-section-title:hover { background: #f2f6fb; }
.measurement-section-title:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
.measurement-section-title[aria-expanded='false'] { border-radius: 7px; }
.measurement-section-title h3 { flex: 0 0 auto; margin: 0; color: var(--cc-text); font-size: 14px; }
.measurement-section-summary { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 18px; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.measurement-section-title > svg { flex: 0 0 auto; color: var(--cc-muted); }
.measurement-section-toggle { display: flex; min-width: 0; flex: 1; align-items: center; gap: 16px; padding: 2px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.measurement-section-toggle:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
.measurement-section-toggle > svg { flex: 0 0 auto; color: var(--cc-muted); }
.measurement-section-mode { flex: 0 0 190px; }
.measurement-section-title > .measurement-section-mode { display: none; }
.measurement-rule-selector { display: flex; align-items: center; gap: 14px; margin: 8px 0 12px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 7px; background: #f8fafc; }
.measurement-rule-selector__label { color: var(--cc-text); font-size: 12px; font-weight: 650; white-space: nowrap; }
.measurement-rule-selector__control { width: 270px; }
.measurement-rule-selector__hint { color: var(--cc-muted); font-size: 11px; }
.measurement-total { display: flex; min-height: 42px; align-items: center; justify-content: flex-end; gap: 24px; padding: 9px 14px; border: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.measurement-total { border-top: 0; border-radius: 0 0 7px 7px; }
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
@@ -1824,5 +2328,6 @@ onBeforeUnmount(() => {
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
@media (max-width: 720px) { .fee-overview { grid-template-columns: 1fr; } .fee-overview > div + div { border-top: 1px solid var(--cc-border); border-left: 0; } }
@media (max-width: 720px) { .measurement-section-title { align-items: stretch; flex-direction: column; } .measurement-section-toggle { align-items: flex-start; flex-wrap: wrap; } .measurement-section-toggle > svg { margin-left: auto; } .measurement-section-mode { width: 100%; flex-basis: auto; } }
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
</style>