diff --git a/src/modules/customers/components/CustomerBillingRulesPanel.test.ts b/src/modules/customers/components/CustomerBillingRulesPanel.test.ts index 202edf9c..7a7472e3 100644 --- a/src/modules/customers/components/CustomerBillingRulesPanel.test.ts +++ b/src/modules/customers/components/CustomerBillingRulesPanel.test.ts @@ -34,8 +34,8 @@ const config: CustomerDimensionConfig = { rules: boxes.map(([boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, remark], index) => ({ id: String(100 + index), boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm: 2, - outerLengthCm: innerLengthCm + 2, outerWidthCm: innerWidthCm + 2, outerHeightCm: innerHeightCm + 2, - volumeCbm: Number(((innerLengthCm + 2) * (innerWidthCm + 2) * (innerHeightCm + 2) / 1_000_000).toFixed(3)), + outerLengthCm: innerLengthCm + 4, outerWidthCm: innerWidthCm + 4, outerHeightCm: innerHeightCm + 4, + volumeCbm: Number(((innerLengthCm + 4) * (innerWidthCm + 4) * (innerHeightCm + 4) / 1_000_000).toFixed(3)), remark, version: 3, createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:20:00', })), } @@ -71,11 +71,11 @@ describe('CustomerBillingRulesPanel', () => { await flushPromises() expect(getCustomerDimensionConfig).toHaveBeenCalledWith('8') expect(document.body.textContent).toContain('按箱规尺寸计算') - expect(document.body.textContent).toContain('长加 X、宽加 X、高加 X = 外径尺寸') + expect(document.body.textContent).toContain('长、宽、高各加 2X = 外径尺寸') expect(document.body.textContent).toContain('小箱') expect(document.body.textContent).toContain('特大箱') - expect(document.body.textContent).toContain('32 × 22 × 22') - expect(document.body.textContent).toContain('0.015') + expect(document.body.textContent).toContain('34 × 24 × 24') + expect(document.body.textContent).toContain('0.020') expect(document.body.textContent).toContain('标准中箱') }) diff --git a/src/modules/customers/components/CustomerBillingRulesPanel.vue b/src/modules/customers/components/CustomerBillingRulesPanel.vue index 3dd4e0e6..01cbf992 100644 --- a/src/modules/customers/components/CustomerBillingRulesPanel.vue +++ b/src/modules/customers/components/CustomerBillingRulesPanel.vue @@ -31,7 +31,7 @@ const loadError = ref(null) const editorVisible = ref(false) const editorSaving = ref(false) const editorDirty = ref(false) -const defaultCalculationMode = ref<'DIMENSION' | 'WEIGHT'>('DIMENSION') +const defaultCalculationMode = ref<'ACTUAL' | 'DIMENSION' | 'WEIGHT'>('ACTUAL') const defaultModeSaving = ref(false) const editorForm = ref() const form = reactive({ rules: [] }) @@ -71,7 +71,7 @@ const BOX_NAMES: Record = { const columns: DataTableColumn[] = [ { key: 'boxTypeName', label: '箱规类型', minWidth: 110, slotName: 'boxTypeName' }, { key: 'inner', label: '内径尺寸(长×宽×高 cm)', minWidth: 210, slotName: 'inner' }, - { key: 'edgeAdditionCm', label: '尺寸增加值(cm)', width: 150, slotName: 'edgeAdditionCm' }, + { key: 'edgeAdditionCm', label: '单边加宽(cm)', width: 150, slotName: 'edgeAdditionCm' }, { key: 'outer', label: '外径尺寸(长×宽×高 cm)', minWidth: 210, slotName: 'outer' }, { key: 'volumeCbm', label: '体积(CBM)', width: 130, slotName: 'volumeCbm' }, { key: 'remark', label: '备注', minWidth: 140, prop: 'remark' }, @@ -91,7 +91,7 @@ watch(form, () => { watch(() => props.customerId, () => { editorVisible.value = false - defaultCalculationMode.value = 'DIMENSION' + defaultCalculationMode.value = 'ACTUAL' void loadConfig() }) @@ -107,7 +107,8 @@ async function loadConfig() { try { config.value = await getCustomerDimensionConfig(props.customerId) config.value.rules = sortRules(config.value.rules) - defaultCalculationMode.value = config.value.defaultCalculationMode === 'MULTIPLY' ? 'WEIGHT' : 'DIMENSION' + defaultCalculationMode.value = config.value.defaultCalculationMode === 'MULTIPLY' ? 'WEIGHT' + : config.value.defaultCalculationMode === 'ADD' ? 'DIMENSION' : 'ACTUAL' } catch (error) { config.value = null loadError.value = getApiErrorDetails(error, '箱规尺寸规则加载失败,请稍后重试') @@ -168,7 +169,7 @@ function validateRules() { return false } if (form.rules.some((rule) => !Number.isFinite(rule.edgeAdditionCm) || rule.edgeAdditionCm < 0.001 || rule.edgeAdditionCm > 100)) { - ElMessage.error('尺寸增加值必须在 0.001 至 100cm 之间') + ElMessage.error('单边加宽必须在 0.001 至 100cm 之间') return false } if (form.rules.some((rule) => (rule.remark || '').length > 500)) { @@ -207,19 +208,20 @@ function formatDimension(...values: number[]) { return values.map((value) => Number(value.toFixed(2)).toString()).join(' × ') } -async function selectDefaultCalculationMode(mode: 'DIMENSION' | 'WEIGHT') { - if (!canEdit.value || defaultModeSaving.value || mode === defaultCalculationMode.value) return - if (mode === 'WEIGHT' && !visibleChargeRules.value.some((rule) => rule.defaultRule)) { +async function selectDefaultCalculationMode(mode: 'DIMENSION' | 'WEIGHT', checked = true) { + const targetMode = checked ? mode : 'ACTUAL' + if (!canEdit.value || defaultModeSaving.value || targetMode === defaultCalculationMode.value) return + if (checked && mode === 'WEIGHT' && !visibleChargeRules.value.some((rule) => rule.defaultRule)) { ElMessage.warning('请先保存并启用一套权重系数配置,再将其设为默认规则') return } const previous = defaultCalculationMode.value - defaultCalculationMode.value = mode + defaultCalculationMode.value = targetMode defaultModeSaving.value = true try { config.value = await updateCustomerDimensionDefaultCalculationMode( props.customerId, - mode === 'WEIGHT' ? 'MULTIPLY' : 'ADD', + !checked ? 'ACTUAL' : mode === 'WEIGHT' ? 'MULTIPLY' : 'ADD', ) config.value.rules = sortRules(config.value.rules) ElMessage.success('默认计算规则已更新') @@ -234,10 +236,11 @@ async function selectDefaultCalculationMode(mode: 'DIMENSION' | 'WEIGHT') { async function saveChargeConfig() { const coefficient = Number(chargeRows[0].coefficient) if (!canChargeEdit.value || chargeRows.some((row) => !(Number(row.coefficient) > 0))) { ElMessage.warning('权重系数必须大于 0'); return } + if (chargeRows.some((row) => !(Number(row.weight) > 0))) { ElMessage.warning('每档重量必须大于 0'); return } chargeSaving.value = true try { // A saved version becomes the active coefficient set immediately. - const created = await createCustomerChargeRule(props.customerId, { ...chargeForm, ruleCode: `RULE-${Date.now()}`, lengthFactor: coefficient, widthFactor: coefficient, heightFactor: coefficient, defaultRule: false, remark: chargeRows.map((row) => `${row.boxType}:${row.length},${row.width},${row.height};系数:${row.coefficient};${BOX_NAMES[row.boxType]}:${row.remark || '—'}`).join(';') }) + const created = await createCustomerChargeRule(props.customerId, { ...chargeForm, ruleCode: `RULE-${Date.now()}`, lengthFactor: coefficient, widthFactor: coefficient, heightFactor: coefficient, defaultRule: false, remark: chargeRows.map((row) => `${row.boxType}:${row.length},${row.width},${row.height};系数:${row.coefficient};重量:${row.weight};${BOX_NAMES[row.boxType]}:${row.remark || '—'}`).join(';') }) await setCustomerChargeRuleDefault(props.customerId, created.id) await loadChargeRules() editorDirty.value = false @@ -274,6 +277,8 @@ async function loadChargeRules() { if (match) { row.length = Number(match[1]); row.width = Number(match[2]); row.height = Number(match[3]) } const coefficient = remark.match(new RegExp(`${row.boxType}(?::[^;]*)?;系数:(\\d+(?:\\.\\d+)?)`)) row.coefficient = coefficient ? Number(coefficient[1]) : fallbackCoefficient + const weight = remark.match(new RegExp(`${row.boxType}(?::[^;]*)?;系数:[^;]+;重量:(\\d+(?:\\.\\d+)?)`)) + if (weight) row.weight = Number(weight[1]) const note = remark.match(new RegExp(`${BOX_NAMES[row.boxType]}:([^;]+)`)) if (note) row.remark = note[1] }) @@ -296,15 +301,15 @@ onMounted(() => { void loadConfig(); void loadChargeRules() })

按箱规尺寸计算

- 设为默认规则 + 设为默认规则
编辑规则
-
计算方式:长加 X、宽加 X、高加 X = 外径尺寸(X 为尺寸增加值,按实测尺寸匹配最小可容纳箱规)体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM超出当前箱规时自动匹配下一档箱规
+
计算方式:长、宽、高各加 2X = 外径尺寸(X 为单边加宽,按实测尺寸匹配箱规)体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM低于最低档使用最低档,超出当前档自动匹配下一档,超出最大档使用最大档
- + @@ -313,7 +318,7 @@ onMounted(() => { void loadConfig(); void loadChargeRules() })
-

箱规按权重系数计算

设为默认规则

勾选的规则将作为该客户订单“外箱规数据”的默认计算方式,同一时间只能选择一个;权重系数用于调整不同箱型的计费体积:加权体积 = 体积 × 权重系数,体积重 = 加权体积 × 167 kg/CBM。

+

箱规按权重系数计算

设为默认规则

勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。

@@ -328,10 +333,10 @@ onMounted(() => { void loadConfig(); void loadChargeRules() })
-

计算公式:按长、宽、高匹配最小可容纳箱规;长加 X、宽加 X、高加 X(X 为尺寸增加值),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000

+

计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000

- +
箱规类型内径长(cm)内径宽(cm)内径高(cm)尺寸增加值(cm)备注
箱规类型内径长(cm)内径宽(cm)内径高(cm)单边加宽(cm)备注
{{ row.boxTypeName }}
diff --git a/src/modules/dimension-config/api.ts b/src/modules/dimension-config/api.ts index 9a5f2cd8..ae967ab1 100644 --- a/src/modules/dimension-config/api.ts +++ b/src/modules/dimension-config/api.ts @@ -24,7 +24,8 @@ function normalizeRule(rule: RawRule): CustomerDimensionRule { function normalizeConfig(config: RawConfig): CustomerDimensionConfig { return { tenantId: String(config.tenantId), - defaultCalculationMode: config.defaultCalculationMode === 'MULTIPLY' ? 'MULTIPLY' : 'ADD', + defaultCalculationMode: config.defaultCalculationMode === 'MULTIPLY' ? 'MULTIPLY' + : config.defaultCalculationMode === 'ACTUAL' ? 'ACTUAL' : 'ADD', rules: config.rules.map(normalizeRule), } } diff --git a/src/modules/dimension-config/types.ts b/src/modules/dimension-config/types.ts index df5e81af..5da4ed07 100644 --- a/src/modules/dimension-config/types.ts +++ b/src/modules/dimension-config/types.ts @@ -1,5 +1,5 @@ export type CustomerDimensionBoxType = 'SMALL' | 'MEDIUM' | 'LARGE' | 'EXTRA_LARGE' -export type CustomerDimensionCalculationMode = 'ADD' | 'MULTIPLY' +export type CustomerDimensionCalculationMode = 'ACTUAL' | 'ADD' | 'MULTIPLY' export interface CustomerDimensionRule { id: string diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index d9d84726..73c7ae96 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -43,6 +43,8 @@ const snapshot: InboundFeeSnapshot = { rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null, calculatedAt: '2026-08-04T10:00:00', confirmedAt: '2026-08-04T10:01:00', sentAt: '2026-08-04T10:02:00', nextProcessStatus: 'WAIT_SPLIT', + customerConfirmationStatus: 'PENDING_CONFIRMATION', customerConfirmedBy: null, + customerConfirmedByName: null, customerConfirmedAt: null, } enableAutoUnmount(afterEach) diff --git a/src/modules/inbounds/fee/types.ts b/src/modules/inbounds/fee/types.ts index f2c6583d..305d247a 100644 --- a/src/modules/inbounds/fee/types.ts +++ b/src/modules/inbounds/fee/types.ts @@ -8,6 +8,8 @@ export interface InboundFeeSnapshot { feeStatus: InboundFeeStatus inboundId: string inboundCode: string | null + measurementId?: string | null + measurementSource?: string | null chargeRuleId: string ruleCode: string chargeRuleVersion: number @@ -44,6 +46,10 @@ export interface InboundFeeSnapshot { confirmedAt: string | null sentAt: string | null nextProcessStatus: string | null + customerConfirmationStatus: 'PENDING_CONFIRMATION' | 'CONFIRMED' + customerConfirmedBy: string | null + customerConfirmedByName: string | null + customerConfirmedAt: string | null } export interface AdjustPublishedInboundFeeRequest { diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue index eacbf090..40f15a67 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue @@ -10,7 +10,6 @@ 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, @@ -21,7 +20,6 @@ const props = defineProps<{ customerId?: string tenantId?: string inboundId: string - openWarehouseSuggestion?: boolean }>() const emit = defineEmits<{ @@ -62,15 +60,10 @@ 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) @@ -105,7 +98,6 @@ 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 || '')) @@ -123,9 +115,6 @@ 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) interface MeasurementDeviationWarning { key: string @@ -305,36 +294,6 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem { } } -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 @@ -366,11 +325,6 @@ 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 @@ -603,17 +557,6 @@ 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, () => { @@ -623,7 +566,6 @@ watch( operationError.value = null conflictDetected.value = false validationErrors.value = {} - warehouseSuggestionAutoOpened.value = false clearForm() if (hasContext.value && canView.value) void loadMeasurement() }, @@ -733,7 +675,6 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may

箱规明细

- 添加仓库建议数据 手动录入箱规
@@ -789,15 +730,6 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may /> - - - @@ -812,18 +744,6 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may -
-

仓库建议数据

- - - - - - - - - -
@@ -837,21 +757,6 @@ 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³
-
-
-
diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts index 2f1141e5..52fce114 100644 --- a/src/modules/inbounds/measurement/types.ts +++ b/src/modules/inbounds/measurement/types.ts @@ -34,8 +34,12 @@ export interface InboundMeasurementItem { outerLengthCm: number | null outerWidthCm: number | null outerHeightCm: number | null + calculatedOuterLengthCm?: number | null + calculatedOuterWidthCm?: number | null + calculatedOuterHeightCm?: number | null + calculationRuleVersion?: string | null chargeRuleId?: MeasurementIdentifier | null - coefficientMode?: 'ADD' | 'MULTIPLY' | null + coefficientMode?: 'ACTUAL' | 'ADD' | 'MULTIPLY' | null remark: string | null } @@ -133,7 +137,7 @@ export interface SaveInboundMeasurementItemRequest { outerWidthCm: number | null outerHeightCm: number | null chargeRuleId?: MeasurementIdentifier | null - coefficientMode?: 'ADD' | 'MULTIPLY' | null + coefficientMode?: 'ACTUAL' | '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 d9fc1df2..0d8997e6 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -7,6 +7,12 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/p const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/routes.ts'), 'utf8') describe('UnifiedOrdersView contract', () => { + it('blocks confirm-send when the customer has already confirmed the fee', () => { + expect(source).toContain("snapshot.customerConfirmationStatus === 'CONFIRMED'") + expect(source).toContain('客户已确认该费用,费用已锁定,不允许再修改或重新发送') + expect(source).toContain('async function openFeeConfirmDialog()') + }) + it('only exposes the six product main statuses', () => { expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([ '待审核', '待测量数据', '待分柜', '装柜中', '装柜完成', '派送完成', @@ -337,35 +343,61 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('getCustomerDimensionConfig') expect(source).toContain('matchingDimensionRule') expect(source).toContain('|| rules[rules.length - 1]') - expect(source).toContain('`原尺寸 + ${values[0]}cm`') + expect(source).toContain('`原尺寸 + ${Number(values[0]) * 2}cm(单边 +${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', () => { + it('keeps preview modes independent and persists both outer-carton selectors immediately', () => { 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).not.toContain("updateMeasurementSectionMode('outer', $event)") + expect(source.match(/@change="selectAndSaveOuterMeasurementMode\(\$event\)"/g)).toHaveLength(2) + expect(source.match(/:disabled="Boolean\(measurementStrategySavingId\) \|\| !can\('platform:firstleg:inbound:measure:edit'\)"/g)).toHaveLength(2) + expect(source).toContain('const previousMode = measurementSectionModes.outer') + expect(source).toContain('if (!saved) measurementSectionModes.outer = previousMode') + expect(source).toContain('const saved = await updateMeasurementCalculationMode(null, mode)') expect(source).toContain('calculatedMeasurementRow(row, measurementSectionModes.customer, true)') expect(source).toContain('calculatedMeasurementRow(source, measurementSectionModes.outer)') expect(source).toContain('calculatedMeasurementRow(row as unknown as Record, measurementSectionModes.actual)') expect(source).toContain("return Number(hasOuterOverride && override != null && override !== '' ? override : value)") - expect(source).toContain('rawDimensions.map((value) => value + rule.edgeAdditionCm)') + expect(source).toContain('rawDimensions.map((value) => value + rule.edgeAdditionCm * 2)') expect(source).toContain('matchedSpecificationCoefficient(chargeRule, calculationSource)') expect(source).toContain('listCustomerChargeRules(customerId, tenantId)') expect(source).toContain('matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))') - expect(source).toContain("const selectedMode: 'ADD' | 'MULTIPLY' = measurementSectionModes.outer === 'MULTIPLY'") - expect(source).toContain('? selectedMode') + expect(source).toContain('const selectedMode: MeasurementCalculationMode = measurementSectionModes.outer') 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('confirms customer billing from the saved outer-carton totals', () => { + expect(source).toContain('const outerTotals = measurementTotals(outerMeasurementRows.value)') + expect(source).toContain("ElMessage.warning('外箱规数据不完整,请先保存外箱规尺寸和计算规则')") + expect(source).toContain("weightSource: 'OUTER'") + expect(source).toContain("volumeSource: 'OUTER'") + expect(source).toContain('selectAndSaveOuterMeasurementMode($event)') + expect(source).toContain('const saved = await updateMeasurementCalculationMode(null, mode)') + expect(source).toContain('coefficientMode: targetIds.has(String(item.id)) ? mode') + expect(source).not.toContain('feeConfirmVolume.value = Number(summary.measurement?.actualVolumeCbm)') + const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange')) + expect(sendFeeSource).not.toContain('saveInboundMeasurementDraft') + expect(sendFeeSource).not.toContain('updateMeasurementCalculationMode') + }) + + it('restores the saved outer-carton rule and server-calculated dimensions on re-entry', () => { + expect(source).toContain("mode is MeasurementCalculationMode => mode === 'ACTUAL' || mode === 'ADD' || mode === 'MULTIPLY'") + expect(source).toContain('measurementSectionModes.outer = savedModes.length === 1 ? savedModes[0]!') + expect(source).toContain('row.calculatedOuterLengthCm') + expect(source).toContain('measurementCalculationModeForRow(source) === measurementSectionModes.outer') + expect(source).toContain('lengthCm: Number(savedDimensions[0])') + }) + it('binds dimension and weight evidence to their formal server records without mixing galleries', () => { expect(source).toContain('): CustomerDimensionR 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 (values.length === 1) return `原尺寸 + ${Number(values[0]) * 2}cm(单边 +${values[0]}cm)` if (measurementDimensionConfigLoading.value) return '原尺寸 + 加载中…' return values.length > 1 ? '原尺寸 + 按箱规匹配' : '原尺寸 + 未匹配箱规' } @@ -305,18 +305,22 @@ function multiplyCoefficientLabel() { 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, + /(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\d+(?:\.\d+)?),(\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]), + maxWeightKg: match[6] == null ? null : Number(match[6]), })).filter((tier) => tier.dimensions.every((value) => Number.isFinite(value) && value > 0) - && Number.isFinite(tier.coefficient) && tier.coefficient > 0) + && Number.isFinite(tier.coefficient) && tier.coefficient > 0 + && (tier.maxWeightKg == null || (Number.isFinite(tier.maxWeightKg) && tier.maxWeightKg > 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])) + const unitWeight = Number(row.unitWeightKg) + const matched = tiers.find((tier) => measured.every((value, index) => value <= tier.dimensions[index]) + && (tier.maxWeightKg == null || !Number.isFinite(unitWeight) || unitWeight <= tier.maxWeightKg)) return (matched || tiers[tiers.length - 1]).coefficient } @@ -351,7 +355,7 @@ function calculatedMeasurementRow( if (!rawDimensions.every((value) => Number.isFinite(value) && value > 0)) return { ...source } const matchedDimensions = mode === 'ADD' && rule - ? rawDimensions.map((value) => value + rule.edgeAdditionCm) + ? rawDimensions.map((value) => value + rule.edgeAdditionCm * 2) : rawDimensions const chargeRule = activeMeasurementChargeRule.value const specificationCoefficient = mode === 'MULTIPLY' && chargeRule @@ -364,9 +368,13 @@ function calculatedMeasurementRow( ? 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 fallbackMeasuredUnitWeight = !customerSource && Number.isFinite(Number(measurementDetail.value?.actualGrossWeightKg)) + && Number(measurementDetail.value?.actualPackageCount) > 0 + ? Number(measurementDetail.value?.actualGrossWeightKg) / Number(measurementDetail.value?.actualPackageCount) + : null const sourceUnitWeight = customerSource ? Number(source.grossWeightKg) / packageCount - : Number(source.unitWeightKg) + : Number.isFinite(Number(source.unitWeightKg)) ? Number(source.unitWeightKg) : fallbackMeasuredUnitWeight const unitWeightKg = Number.isFinite(sourceUnitWeight) ? sourceUnitWeight : null const totalWeightKg = unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null return { @@ -383,6 +391,20 @@ const customerMeasurementRows = computed(() => (detail.value?.order.cargoes || [ ))) const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => { const source = row as unknown as Record + const savedDimensions = [row.calculatedOuterLengthCm, row.calculatedOuterWidthCm, row.calculatedOuterHeightCm] + if (savedDimensions.every((value) => Number.isFinite(Number(value)) && Number(value) > 0) + && measurementCalculationModeForRow(source) === measurementSectionModes.outer) { + const packageCount = Number(row.packageCount) + const unitWeightKg = Number.isFinite(Number(row.unitWeightKg)) ? Number(row.unitWeightKg) : null + return { + ...source, + lengthCm: Number(savedDimensions[0]), + widthCm: Number(savedDimensions[1]), + heightCm: Number(savedDimensions[2]), + unitWeightKg, + totalWeightKg: unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null, + } + } return calculatedMeasurementRow(source, measurementSectionModes.outer) })) const actualMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => ( @@ -397,6 +419,18 @@ function updateMeasurementSectionMode(section: MeasurementSection, mode: Measure measurementSectionModes[section] = mode } +async function selectAndSaveOuterMeasurementMode(mode: MeasurementCalculationMode) { + if (measurementStrategySavingId.value) return + const previousMode = measurementSectionModes.outer + if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) { + ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则') + return + } + measurementSectionModes.outer = mode + const saved = await updateMeasurementCalculationMode(null, mode) + if (!saved) measurementSectionModes.outer = previousMode +} + const statusTabs = computed(() => [ { value: '' as const, label: '全部' }, ...unifiedOrderStatusOptions, @@ -777,6 +811,10 @@ function measurementValue(row: Record, key: string, customerSou const count = Number(row.packageCount) return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatNumber(total / count, 4) : '—' } + if (key === 'unitWeightKg') { + const value = Number(row[key]) + return Number.isFinite(value) ? formatNumber(value, 4) : '—' + } if (customerSource && key === 'unitVolumeCbm') { const total = Number(row.volumeCbm) const count = Number(row.packageCount) @@ -804,10 +842,10 @@ function measurementCalculationModeForRow(row: Record): Measure return measuredItem?.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : measuredItem?.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL' } -async function updateMeasurementCalculationMode(row: Record | null, mode: Exclude) { +async function updateMeasurementCalculationMode(row: Record | null, mode: MeasurementCalculationMode): Promise { const summary = detail.value?.summary const measurement = measurementDetail.value - if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null || !can('platform:firstleg:inbound:measure:edit')) return + if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null || !can('platform:firstleg:inbound:measure:edit')) return false const itemId = normalizedIdentifier(row?.id) const itemNo = normalizedIdentifier(row?.itemNo) const targetIds = new Set(measurement.items @@ -815,7 +853,7 @@ async function updateMeasurementCalculationMode(row: Record | n .map((item) => String(item.id))) if (!targetIds.size) { ElMessage.warning('当前数据尚未关联实测明细,暂不能设置计算方式') - return + return false } measurementStrategySavingId.value = row ? (itemId || `item-${itemNo}`) : 'all' try { @@ -826,7 +864,7 @@ async function updateMeasurementCalculationMode(row: Record | n ) === 'MULTIPLY') if (requiresMultiply && !activeRule) { ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则') - return + return false } await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), { measurementId: String(measurement.measurementId), @@ -852,17 +890,19 @@ async function updateMeasurementCalculationMode(row: Record | n chargeRuleId: (targetIds.has(String(item.id)) ? mode : item.coefficientMode) === 'MULTIPLY' ? activeRule!.id : null, - coefficientMode: targetIds.has(String(item.id)) ? mode : item.coefficientMode || 'ADD', + coefficientMode: targetIds.has(String(item.id)) ? mode : item.coefficientMode || 'ACTUAL', remark: item.remark, })), }) measurementLoadedKey.value = '' await loadMeasurementDetails() ElMessage.success(`${row ? '当前数据' : '全部测量数据'}的计算方式已更新,体积已重新计算`) + return true } catch (error) { measurementLoadedKey.value = '' await loadMeasurementDetails() ElMessage.error(getApiErrorDetails(error, '计算方式更新失败').message) + return false } finally { measurementStrategySavingId.value = '' } @@ -892,9 +932,7 @@ async function saveWarehouseOuterDimensions(row: Record) { ElMessage.warning('仓库修改外箱尺寸必须大于 0') return } - const selectedMode: 'ADD' | 'MULTIPLY' = measurementSectionModes.outer === 'MULTIPLY' - ? 'MULTIPLY' - : 'ADD' + const selectedMode: MeasurementCalculationMode = measurementSectionModes.outer const selectedChargeRuleId = selectedMode === 'MULTIPLY' ? activeMeasurementChargeRule.value?.id || null : null @@ -924,7 +962,7 @@ async function saveWarehouseOuterDimensions(row: Record) { chargeRuleId: String(item.id) === String(row.id) ? selectedChargeRuleId : item.chargeRuleId, coefficientMode: String(item.id) === String(row.id) ? selectedMode - : item.coefficientMode || 'ADD', + : item.coefficientMode || 'ACTUAL', remark: item.remark, })), }) @@ -1054,11 +1092,16 @@ async function loadMeasurementDetails() { loadMeasurementDimensionConfig(), ]) if (serial !== measurementRequestSerial) return - const configuredMode = measurementDimensionConfig.value?.defaultCalculationMode - measurementSectionModes.outer = configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value - ? 'MULTIPLY' - : 'ADD' measurementDetail.value = response + const savedModes = [...new Set(response.items.map((item) => item.coefficientMode) + .filter((mode): mode is MeasurementCalculationMode => mode === 'ACTUAL' || mode === 'ADD' || mode === 'MULTIPLY'))] + const configuredMode = measurementDimensionConfig.value?.defaultCalculationMode + // 已保存的明细规则优先于客户默认配置,确保再次进入时回显上次选择及其计算结果。 + measurementSectionModes.outer = savedModes.length === 1 ? savedModes[0]! + : configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value ? 'MULTIPLY' + : configuredMode === 'ADD' ? 'ADD' : 'ACTUAL' + measurementSectionModes.customer = measurementSectionModes.outer + measurementSectionModes.actual = 'ACTUAL' measurementLoadedKey.value = key } catch (error) { if (serial !== measurementRequestSerial) return @@ -1286,7 +1329,7 @@ function openMeasurement(row: UnifiedOrderResource) { measurementDialogOpen.value = true } -function openFeeConfirmDialog() { +async function openFeeConfirmDialog() { const summary = detail.value?.summary const reviewStatus = String(summary?.measurement?.reviewStatus ?? '').trim().toUpperCase() if (reviewStatus !== 'APPROVED') { @@ -1300,8 +1343,28 @@ function openFeeConfirmDialog() { if (confirmFeeSending.value) { return } - feeConfirmWeight.value = Number(summary.measurement?.actualGrossWeightKg) || null - feeConfirmVolume.value = Number(summary.measurement?.actualVolumeCbm) || null + try { + const snapshot = await getLatestInboundFee(String(summary.tenantId), String(summary.inboundId)) + if (snapshot.customerConfirmationStatus === 'CONFIRMED' || snapshot.customerConfirmedAt) { + ElMessage.warning('客户已确认该费用,费用已锁定,不允许再修改或重新发送') + return + } + } catch (error) { + ElMessage.error(getApiErrorDetails(error, '费用状态查询失败,请刷新后重试').message) + return + } + if (measurementStrategySavingId.value) { + ElMessage.warning('外箱规计算规则正在保存,请稍后再发送费用') + return + } + const outerTotals = measurementTotals(outerMeasurementRows.value) + if (!outerMeasurementRows.value.length || !(Number(outerTotals.gross) > 0) + || !(Number(outerTotals.volume) > 0)) { + ElMessage.warning('外箱规数据不完整,请先保存外箱规尺寸和计算规则') + return + } + feeConfirmWeight.value = Number(outerTotals.gross) + feeConfirmVolume.value = Number(outerTotals.volume) feeConfirmOpen.value = true } @@ -1319,12 +1382,17 @@ async function executeConfirmAndSendFee() { const tenantId = String(summary.tenantId) const inboundId = String(summary.inboundId) let snapshot = await getLatestInboundFee(tenantId, inboundId) + if (snapshot.customerConfirmationStatus === 'CONFIRMED' || snapshot.customerConfirmedAt) { + ElMessage.warning('客户已确认该费用,费用已锁定,不允许再修改或重新发送') + feeConfirmOpen.value = false + return + } if (snapshot.feeStatus === 'CALCULATED') { snapshot = await calculateInboundFee(tenantId, inboundId, { selectedWeightKg, selectedVolumeCbm, - weightSource: 'ACTUAL', - volumeSource: 'ACTUAL', + weightSource: 'OUTER', + volumeSource: 'OUTER', }, createFeeActionIdempotencyKey('calculate')) snapshot = await confirmInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('confirm')) } @@ -1982,7 +2050,7 @@ onBeforeUnmount(() => {
-
+
合计箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}总材积:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}
@@ -1990,7 +2058,7 @@ onBeforeUnmount(() => {
箱规计算规则选择 - +