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 @@ 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

箱规明细

- 手动录入箱规 +
+ 添加仓库建议数据 + 手动录入箱规 +
- + + + + + + + + + + + + + @@ -626,6 +735,18 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may +
+

仓库建议数据

+ + + + + + + + + +
@@ -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(() => {