From 627057309598c18d99c8954f557e26e8fe3fdfe1 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 20:49:19 +0800
Subject: [PATCH 1/5] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
.../customers/pages/CustomersView.test.ts | 7 ++++
src/modules/customers/pages/CustomersView.vue | 41 ++++++++++++++-----
src/modules/finance/AGENTS.md | 3 +-
4 files changed, 40 insertions(+), 13 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 6d5ef77b..dac37c46 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关逐资料完成,全买单柜使用 `complete-all-buy` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts
index 1007ec75..fd7bc406 100644
--- a/src/modules/customers/pages/CustomersView.test.ts
+++ b/src/modules/customers/pages/CustomersView.test.ts
@@ -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('公司名称、客户编号或联系人')
diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue
index b10b2bcf..0fa5c493 100644
--- a/src/modules/customers/pages/CustomersView.vue
+++ b/src/modules/customers/pages/CustomersView.vue
@@ -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()
diff --git a/src/modules/finance/AGENTS.md b/src/modules/finance/AGENTS.md
index a4fddf3b..88accc96 100644
--- a/src/modules/finance/AGENTS.md
+++ b/src/modules/finance/AGENTS.md
@@ -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 中重建付款事实。
From 3c68ef4ddff30c343eb6fb9b072a2a73240c0b31 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 3 Sep 2026 10:27:31 +0800
Subject: [PATCH 2/5] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/SmartLoadingDialog.test.ts | 30 ++++++
.../components/SmartLoadingDialog.vue | 45 ++++++++-
.../pages/GlobalSmartLoadingView.test.ts | 58 +++++++++++-
.../pages/GlobalSmartLoadingView.vue | 75 +++++++++++----
.../smartLoadingChannelGroups.test.ts | 42 +++++++++
.../smartLoadingChannelGroups.ts | 91 +++++++++++++++++++
6 files changed, 316 insertions(+), 25 deletions(-)
create mode 100644 src/modules/container-loading/components/SmartLoadingDialog.test.ts
create mode 100644 src/modules/container-loading/smartLoadingChannelGroups.test.ts
create mode 100644 src/modules/container-loading/smartLoadingChannelGroups.ts
diff --git a/src/modules/container-loading/components/SmartLoadingDialog.test.ts b/src/modules/container-loading/components/SmartLoadingDialog.test.ts
new file mode 100644
index 00000000..9090f471
--- /dev/null
+++ b/src/modules/container-loading/components/SmartLoadingDialog.test.ts
@@ -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: '
智能装柜工作台
' } },
+ },
+ })
+ 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)
+ })
+})
diff --git a/src/modules/container-loading/components/SmartLoadingDialog.vue b/src/modules/container-loading/components/SmartLoadingDialog.vue
index 9542f7fb..eca851e0 100644
--- a/src/modules/container-loading/components/SmartLoadingDialog.vue
+++ b/src/modules/container-loading/components/SmartLoadingDialog.vue
@@ -1,8 +1,25 @@
@@ -11,18 +28,44 @@ const open = defineModel({ default: false })
class="smart-loading-dialog"
title="智能装柜"
width="min(1600px, 98vw)"
+ :fullscreen="fullscreen"
cancel-text="关闭"
:show-confirm="false"
destroy-on-close
>
-
+
+
+
+
diff --git a/src/modules/dimension-config/api.ts b/src/modules/dimension-config/api.ts
index 45d2e42e..9a5f2cd8 100644
--- a/src/modules/dimension-config/api.ts
+++ b/src/modules/dimension-config/api.ts
@@ -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 {
+ const response = await http.put>(
+ `${dimensionConfigPath}/default-calculation-mode`,
+ { defaultCalculationMode },
+ { params: { tenantId } },
+ )
+ return normalizeConfig(unwrapApiResponse(response))
+}
diff --git a/src/modules/dimension-config/index.ts b/src/modules/dimension-config/index.ts
index 607ecb99..69235887 100644
--- a/src/modules/dimension-config/index.ts
+++ b/src/modules/dimension-config/index.ts
@@ -1,10 +1,12 @@
export {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
+ updateCustomerDimensionDefaultCalculationMode,
} from './api'
export type {
CustomerDimensionBoxType,
+ CustomerDimensionCalculationMode,
CustomerDimensionConfig,
CustomerDimensionConfigFormModel,
CustomerDimensionRule,
diff --git a/src/modules/dimension-config/types.ts b/src/modules/dimension-config/types.ts
index ce3dbe08..df5e81af 100644
--- a/src/modules/dimension-config/types.ts
+++ b/src/modules/dimension-config/types.ts
@@ -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[]
}
diff --git a/src/modules/fba-pricing/customer-rules/api.ts b/src/modules/fba-pricing/customer-rules/api.ts
new file mode 100644
index 00000000..6efc0ab2
--- /dev/null
+++ b/src/modules/fba-pricing/customer-rules/api.ts
@@ -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 & {
+ 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>(path, {
+ params: { tenantId: tenantId || customerId, customerId },
+ })
+ return unwrapApiResponse(response).map(normalize)
+}
+
+export async function createCustomerChargeRule(tenantId: string, request: CreateCustomerChargeRuleRequest) {
+ const response = await http.post>(`${path}/versions`, request, { params: { tenantId } })
+ return normalize(unwrapApiResponse(response))
+}
+
+export async function setCustomerChargeRuleDefault(tenantId: string, ruleId: string) {
+ await http.put>(`${path}/${encodeURIComponent(ruleId)}/default`, undefined, { params: { tenantId } })
+}
+
+export async function retireCustomerChargeRule(tenantId: string, ruleId: string) {
+ await http.delete>(`${path}/${encodeURIComponent(ruleId)}`, { params: { tenantId } })
+}
diff --git a/src/modules/fba-pricing/index.ts b/src/modules/fba-pricing/index.ts
new file mode 100644
index 00000000..545977d1
--- /dev/null
+++ b/src/modules/fba-pricing/index.ts
@@ -0,0 +1 @@
+export * from './public'
diff --git a/src/modules/fba-pricing/public.ts b/src/modules/fba-pricing/public.ts
new file mode 100644
index 00000000..e0e6fc2b
--- /dev/null
+++ b/src/modules/fba-pricing/public.ts
@@ -0,0 +1,7 @@
+export {
+ createCustomerChargeRule,
+ listCustomerChargeRules,
+ retireCustomerChargeRule,
+ setCustomerChargeRuleDefault,
+} from './customer-rules/api'
+export type { CustomerChargeRule, CreateCustomerChargeRuleRequest } from './customer-rules/api'
diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue
index 190bb852..db29eef6 100644
--- a/src/modules/inbounds/fee/InboundFeePanel.vue
+++ b/src/modules/inbounds/fee/InboundFeePanel.vue
@@ -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(null)
const conflictRefreshed = ref(false)
const snapshot = ref(null)
const adjustOpen = ref(false)
+const trialOpen = ref(false)
+const trialLoading = ref(false)
+const measurement = ref(null)
+const trial = reactive({ weightSource: 'ACTUAL', volumeSource: 'ACTUAL' })
const adjustFormRef = ref()
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 = {
@@ -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()"
>
+ 费用试算
确认费用
+ 确认推送费用
+
+
+
+ 计费重量(单选) {{ item.label }} {{ formatNumber(item.weight) }} KG
+ 计费体积(单选) {{ item.label }} {{ formatNumber(item.volume, 6) }} CBM
+
+
+
当前账号没有查看入库费用的权限
需要权限:firstleg:fba-pricing:view
@@ -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) {
diff --git a/src/modules/inbounds/fee/api.ts b/src/modules/inbounds/fee/api.ts
index e2e0f67d..27993cfb 100644
--- a/src/modules/inbounds/fee/api.ts
+++ b/src/modules/inbounds/fee/api.ts
@@ -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>(
+ `${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)}`
diff --git a/src/modules/inbounds/index.ts b/src/modules/inbounds/index.ts
index 60755eea..3cff1a9e 100644
--- a/src/modules/inbounds/index.ts
+++ b/src/modules/inbounds/index.ts
@@ -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'
diff --git a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
index bc78ba94..d4986eca 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
+++ b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
@@ -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"
diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
index 53603f31..9df5ecb7 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
+++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
@@ -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')
diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
index 8b975b29..af65a406 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
+++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
@@ -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 & {
itemId: string
clientKey: string
serverValues: Pick
+ 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(null)
const manualEntryErrors = ref([])
const detail = ref(null)
+const chargeRules = ref([])
+const chargeRulesLoading = ref(false)
const loadError = ref(null)
const operationError = ref(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
-
+
{{ row.boxCode || '—' }}
{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}
@@ -626,6 +735,18 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
+
+
仓库建议数据
+
+
+ {{ formatNumber(row.outerLengthCm) }} × {{ formatNumber(row.outerWidthCm) }} × {{ formatNumber(row.outerHeightCm) }}
+
+ {{ formatNumber(row.serverValues.girthCm) }}
+ {{ formatNumber(row.serverValues.unitVolumeCbm == null ? null : row.serverValues.unitVolumeCbm * row.packageCount, 6) }}
+ {{ row.coefficientMode === 'MULTIPLY' ? '按箱规尺寸系数' : '按箱规尺寸策略' }}({{ chargeRuleName(row.chargeRuleId) }})
+
+
+
@@ -639,6 +760,21 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
+
+
+
+
+
+
+
+
+
长 × 宽 × 高 {{ formatNumber(suggestionItem.lengthCm) }} × {{ formatNumber(suggestionItem.widthCm) }} × {{ formatNumber(suggestionItem.heightCm) }} cm
+
箱规周长 {{ formatNumber(suggestionGirth) }} cm
+
单箱体积 {{ formatNumber(suggestionItem.serverValues.unitVolumeCbm, 6) }} m³
+
总体积 {{ formatNumber(suggestionTotalVolume, 6) }} m³
+
+
+
@@ -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; }
diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts
index 4484c21d..389d638a 100644
--- a/src/modules/inbounds/measurement/types.ts
+++ b/src/modules/inbounds/measurement/types.ts
@@ -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
}
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
index 32f62461..c1c6bfb7 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
@@ -297,6 +297,14 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('
')
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
>")
+ 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, 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(' {
it('matches the prototype table grid and distinguishes container identifiers', () => {
expect(source).toContain('row-key="orderId" border stripe')
- expect(source).toContain(']*border stripe/g)).toHaveLength(2)
- expect(source).toContain('
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(null)
const measurementLoading = ref(false)
const measurementError = ref(null)
const measurementLoadedKey = ref('')
+const measurementChargeRules = ref([])
+const measurementChargeRulesLoading = ref(false)
+const measurementChargeRulesLoadedKey = ref('')
+const measurementDimensionConfig = ref(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(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>({
+ customer: 'ACTUAL', outer: 'ACTUAL', actual: 'ACTUAL',
+})
const cargoImagePreviewUrls = new Set()
const measurementDialogOpen = ref(false)
+const openWarehouseSuggestionOnMeasurementLoad = ref(false)
+const confirmFeeSending = ref(false)
+const feeConfirmOpen = ref(false)
+const feeConfirmWeight = ref(null)
+const feeConfirmVolume = ref(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(null)
const loadingAssociation = ref(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) {
+ 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): 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) {
+ const rows = row ? [row] : (measurementDetail.value?.items || []) as unknown as Record[]
+ 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) {
+ 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,
+ 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
+ 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, 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, key: string, customerSource = false) {
+function measurementValue(row: Record, 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, key: string, customerSou
return typeof value === 'number' ? formatNumber(value, 6) : String(value)
}
+function measurementCalculationLabel(row: Record) {
+ if (measurementCalculationModeForRow(row) === 'MULTIPLY') {
+ return `乘以${measurementChargeRuleName(row.chargeRuleId)}`
+ }
+ if (measurementCalculationModeForRow(row) === 'ACTUAL') return '实测原尺寸(不调整)'
+ return addCoefficientLabel(row).replace('原尺寸 + ', '加')
+}
+
+function measurementCalculationModeForRow(row: Record): 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 | null, mode: Exclude) {
+ 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) {
+ 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(() => {
+
+
+
+
+ kg
+ m³
+
+ 参考数据
+
+
+
+ {{ row.weight == null ? '—' : formatNumber(row.weight, 2) }}
+ {{ row.volume == null ? '—' : formatNumber(row.volume, 4) }}
+ {{ row.volume == null ? '—' : formatNumber(Number(row.volume) * 167, 2) }}
+
+
- 测量数据 {{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}
+
+
测量数据 {{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}
+
+ 确认费用发送
+
+
@@ -1428,28 +1894,50 @@ onBeforeUnmount(() => {
- 一、客户下单数据 总件数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }} 总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }} 总材积重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }} 重量体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).weightVolume, 6) }}
+ 一、客户下单数据 总件数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }} 总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }} 总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }} 重量体积:{{ formatNumber(measurementTotals(customerMeasurementRows).weightVolume, 6) }}
-
{{ measurementValue(row as Record, column.key, true) }}
-
合计 箱数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }} 总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }} 总材积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}
+
{{ measurementValue(row as Record, column.key) }}
+
合计 箱数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }} 总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }} 总材积:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}
- 二、外箱规数据 总件数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record[]).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).gross) }} 总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).volume, 6) }} 总材积重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).volumetric) }} 重量体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).weightVolume, 6) }}
+ 二、外箱规数据 总件数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }} 总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }} 总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }} 重量体积:{{ formatNumber(measurementTotals(outerMeasurementRows).weightVolume, 6) }}
-
{{ measurementValue(row as Record, column.key) }}
-
合计 箱数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record[]).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).gross) }} 总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).volume, 6) }} 总材积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).volumetric) }}
+
{{ measurementValue(row as Record, column.key) }}
+
合计 箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }} 总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }} 总材积:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}
+
+ 箱规计算规则选择
+
+
+
+
+
+ 规则参数来源:客户管理
+
- 三、实际测量数据 总件数:{{ measurementDetail?.actualPackageCount ?? '—' }} 总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} 总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }} 总材积重:{{ formatNumber(measurementDetail?.volumeWeightKg) }} 重量体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}
+ 三、实际测量数据 总件数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }} 总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }} 总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }} 重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}
-
+
{{ row.boxCode || '—' }}
-
+
+
+ {{ formatNumber(row.outerLengthCm) }}
+
+
+ {{ formatNumber(row.outerWidthCm) }}
+
+
+ {{ formatNumber(row.outerHeightCm) }}
+
+
+ )">保存外箱
+
+
{{ measurementValue(row as Record, column.key) }}
@@ -1466,7 +1954,7 @@ onBeforeUnmount(() => {
-
合计 箱数:{{ measurementDetail?.actualPackageCount ?? '—' }} 总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} 总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }} 总材积:{{ formatNumber(measurementDetail?.volumeWeightKg) }}
+
合计 箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }} 总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }} 总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }} 总材积:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}
称重图片与确认重量 称重图片独立展示;重量值直接采用服务端确认结果。
@@ -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; } }