diff --git a/src/modules/customers/components/CustomerBillingRulesPanel.test.ts b/src/modules/customers/components/CustomerBillingRulesPanel.test.ts index 23981e4e..8a41e82a 100644 --- a/src/modules/customers/components/CustomerBillingRulesPanel.test.ts +++ b/src/modules/customers/components/CustomerBillingRulesPanel.test.ts @@ -2,12 +2,24 @@ import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getCustomerDimensionConfig, updateCustomerDimensionConfig, type CustomerDimensionConfig } from '@/modules/dimension-config' +import { getCustomerDimensionConfig, updateCustomerDimensionConfig, updateCustomerDimensionDefaultCalculationMode, type CustomerDimensionConfig } from '@/modules/dimension-config' +import { + createCustomerChargeRule, + listCustomerChargeRules, + setCustomerChargeRuleDefault, + type CustomerChargeRule, +} from '@/modules/fba-pricing' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue' -vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn() })) +vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn(), updateCustomerDimensionDefaultCalculationMode: vi.fn() })) +vi.mock('@/modules/fba-pricing', () => ({ + createCustomerChargeRule: vi.fn(), + listCustomerChargeRules: vi.fn(), + retireCustomerChargeRule: vi.fn(), + setCustomerChargeRuleDefault: vi.fn(), +})) const boxes = [ ['SMALL', '小箱', 30, 20, 20, '标准小箱'], @@ -17,6 +29,7 @@ const boxes = [ ] as const const config: CustomerDimensionConfig = { tenantId: '8', + defaultCalculationMode: 'ADD', rules: boxes.map(([boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, remark], index) => ({ id: String(100 + index), boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm: 2, @@ -25,6 +38,13 @@ const config: CustomerDimensionConfig = { remark, version: 3, createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:20:00', })), } +const chargeRule: CustomerChargeRule = { + id: '201', tenantId: '8', ruleCode: 'RULE-201', ruleVersion: 1, customerId: '8', customerName: '柏悦物流', + businessType: 'FBA', chargeMode: 'CBM', taxMode: 'TAX_INCLUDED', conversionFactor: 167, + lengthFactor: 1, widthFactor: 1, heightFactor: 1, defaultRule: false, roundingScale: 2, + roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom: '2026-09-02T10:00:00', effectiveTo: null, + status: 'ACTIVE', remark: null, +} enableAutoUnmount(afterEach) @@ -33,10 +53,16 @@ describe('CustomerBillingRulesPanel', () => { vi.clearAllMocks() document.body.innerHTML = '' const pinia = createPinia(); setActivePinia(pinia) - const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['firstleg:dimension-config:view', 'firstleg:dimension-config:edit'] + const auth = useAuthStore(); auth.token = 'token'; auth.permissions = [ + 'firstleg:dimension-config:view', 'firstleg:dimension-config:edit', + 'firstleg:fba-pricing:rule:view', 'firstleg:fba-pricing:rule:edit', + ] useAccessPreviewStore().initializeAfterLogin() vi.mocked(getCustomerDimensionConfig).mockResolvedValue(config) vi.mocked(updateCustomerDimensionConfig).mockResolvedValue(config) + vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue(config) + vi.mocked(listCustomerChargeRules).mockResolvedValue([chargeRule]) + vi.mocked(createCustomerChargeRule).mockResolvedValue(chargeRule) }) it('renders the four fixed carton dimension rows and calculated values', async () => { @@ -90,4 +116,33 @@ describe('CustomerBillingRulesPanel', () => { await flushPromises() expect(document.body.textContent).toContain('编辑箱规尺寸') }) + + it('saves the coefficient set and can use it as the customer default calculation mode', async () => { + vi.mocked(listCustomerChargeRules) + .mockResolvedValueOnce([chargeRule]) + .mockResolvedValueOnce([{ ...chargeRule, defaultRule: true }]) + vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue({ + ...config, + defaultCalculationMode: 'MULTIPLY', + }) + mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + + ;[...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click() + await flushPromises() + expect(document.body.textContent).toContain('箱规按权重系数计算') + + ;[...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存系数规则')?.click() + await flushPromises() + expect(createCustomerChargeRule).toHaveBeenCalledWith('8', expect.objectContaining({ + defaultRule: false, + remark: expect.stringMatching(/SMALL:30,20,20;系数:\d+(?:\.\d+)?/), + })) + expect(setCustomerChargeRuleDefault).toHaveBeenCalledWith('8', '201') + + const defaultRuleCheckboxes = [...document.querySelectorAll('input[type="checkbox"]')] + defaultRuleCheckboxes[1]?.click() + await flushPromises() + expect(updateCustomerDimensionDefaultCalculationMode).toHaveBeenCalledWith('8', 'MULTIPLY') + }) }) diff --git a/src/modules/customers/components/CustomerBillingRulesPanel.vue b/src/modules/customers/components/CustomerBillingRulesPanel.vue index 0891fd28..59e3d805 100644 --- a/src/modules/customers/components/CustomerBillingRulesPanel.vue +++ b/src/modules/customers/components/CustomerBillingRulesPanel.vue @@ -7,6 +7,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe import { getCustomerDimensionConfig, updateCustomerDimensionConfig, + updateCustomerDimensionDefaultCalculationMode, type CustomerDimensionBoxType, type CustomerDimensionConfig, type CustomerDimensionConfigFormModel, @@ -16,19 +17,51 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' +import { createCustomerChargeRule, listCustomerChargeRules, setCustomerChargeRuleDefault, type CustomerChargeRule, type CreateCustomerChargeRuleRequest } from '@/modules/fba-pricing' const props = defineProps<{ customerId: string; customerName: string; customerCode?: string }>() const access = useAccessPreviewStore() const canView = computed(() => access.can('firstleg:dimension-config:view')) const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit')) +const canChargeView = computed(() => access.can('firstleg:fba-pricing:rule:view')) +const canChargeEdit = computed(() => canChargeView.value && access.can('firstleg:fba-pricing:rule:edit')) const config = ref(null) const loading = ref(false) const loadError = ref(null) const editorVisible = ref(false) const editorSaving = ref(false) const editorDirty = ref(false) +const defaultCalculationMode = ref<'DIMENSION' | 'WEIGHT'>('DIMENSION') +const defaultModeSaving = ref(false) const editorForm = ref() const form = reactive({ rules: [] }) +const chargeRules = ref([]) +const visibleChargeRules = computed(() => chargeRules.value.filter((rule) => rule.status === 'ACTIVE')) +const chargeDisplayRows = computed(() => { + const rule = visibleChargeRules.value.find((item) => item.defaultRule) || visibleChargeRules.value[0] + if (!rule) return [] + return BOX_ORDER.map((boxType) => ({ + ...rule, + boxType, + boxTypeName: BOX_NAMES[boxType], + dimension: chargeDimension(boxType), + coefficient: Number(chargeRows.find((item) => item.boxType === boxType)?.coefficient || rule.lengthFactor || 1), + weight: chargeRows.find((item) => item.boxType === boxType)?.weight || 0, + remark: chargeRows.find((item) => item.boxType === boxType)?.remark || '', + })) +}) +const chargeLoading = ref(false) +const chargeSaving = ref(false) +const chargeForm = reactive({ + ruleCode: '', customerId: props.customerId, customerName: props.customerName, businessType: 'FBA', chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167, + lengthFactor: 1, widthFactor: 1, heightFactor: 1, defaultRule: false, roundingScale: 2, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom: new Date().toISOString().slice(0, 19), effectiveTo: null, remark: null, +}) +const chargeRows = reactive([ + { boxType: 'SMALL' as CustomerDimensionBoxType, length: 30, width: 20, height: 20, coefficient: 1.1, weight: 5, coefficientType: '体积系数', remark: '标准小箱' }, + { boxType: 'MEDIUM' as CustomerDimensionBoxType, length: 40, width: 30, height: 30, coefficient: 1.1, weight: 8, coefficientType: '体积系数', remark: '标准中箱' }, + { boxType: 'LARGE' as CustomerDimensionBoxType, length: 50, width: 40, height: 40, coefficient: 1.1, weight: 12, coefficientType: '体积系数', remark: '标准大箱' }, + { boxType: 'EXTRA_LARGE' as CustomerDimensionBoxType, length: 60, width: 50, height: 50, coefficient: 1.1, weight: 15, coefficientType: '体积系数', remark: '特大箱' }, +]) const BOX_ORDER: readonly CustomerDimensionBoxType[] = ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE'] const BOX_NAMES: Record = { @@ -58,6 +91,7 @@ watch(form, () => { watch(() => props.customerId, () => { editorVisible.value = false + defaultCalculationMode.value = 'DIMENSION' void loadConfig() }) @@ -73,6 +107,7 @@ async function loadConfig() { try { config.value = await getCustomerDimensionConfig(props.customerId) config.value.rules = sortRules(config.value.rules) + defaultCalculationMode.value = config.value.defaultCalculationMode === 'MULTIPLY' ? 'WEIGHT' : 'DIMENSION' } catch (error) { config.value = null loadError.value = getApiErrorDetails(error, '箱规尺寸规则加载失败,请稍后重试') @@ -87,6 +122,7 @@ function cloneRules(rules: CustomerDimensionRule[]) { async function openEditor() { if (!config.value) return + Object.assign(chargeForm, { ruleCode: `RULE-${Date.now()}`, customerId: props.customerId, customerName: props.customerName, businessType: 'FBA', chargeMode: 'CBM', taxMode: 'TAX_INCLUDED', conversionFactor: 167, lengthFactor: 1, widthFactor: 1, heightFactor: 1, defaultRule: false, roundingScale: 2, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom: new Date().toISOString().slice(0, 19), effectiveTo: null, remark: null }) form.rules = cloneRules(config.value.rules) await nextTick() editorDirty.value = false @@ -157,8 +193,8 @@ async function saveConfig() { })) config.value = await updateCustomerDimensionConfig(props.customerId, { rules }) config.value.rules = sortRules(config.value.rules) + form.rules = cloneRules(config.value.rules) editorDirty.value = false - editorVisible.value = false ElMessage.success('箱规尺寸规则已保存') } catch (error) { ElMessage.error(getApiErrorDetails(error, '箱规尺寸规则保存失败,请刷新后重试').message) @@ -171,11 +207,80 @@ function formatDimension(...values: number[]) { return values.map((value) => Number(value.toFixed(2)).toString()).join(' × ') } +async function selectDefaultCalculationMode(mode: 'DIMENSION' | 'WEIGHT') { + if (!canEdit.value || defaultModeSaving.value || mode === defaultCalculationMode.value) return + if (mode === 'WEIGHT' && !visibleChargeRules.value.some((rule) => rule.defaultRule)) { + ElMessage.warning('请先保存并启用一套权重系数配置,再将其设为默认规则') + return + } + const previous = defaultCalculationMode.value + defaultCalculationMode.value = mode + defaultModeSaving.value = true + try { + config.value = await updateCustomerDimensionDefaultCalculationMode( + props.customerId, + mode === 'WEIGHT' ? 'MULTIPLY' : 'ADD', + ) + config.value.rules = sortRules(config.value.rules) + ElMessage.success('默认计算规则已更新') + } catch (error) { + defaultCalculationMode.value = previous + ElMessage.error(getApiErrorDetails(error, '默认计算规则保存失败').message) + } finally { + defaultModeSaving.value = false + } +} + +async function saveChargeConfig() { + const coefficient = Number(chargeRows[0].coefficient) + if (!canChargeEdit.value || chargeRows.some((row) => !(Number(row.coefficient) > 0))) { ElMessage.warning('权重系数必须大于 0'); return } + chargeSaving.value = true + try { + // A saved version becomes the active coefficient set immediately. + const created = await createCustomerChargeRule(props.customerId, { ...chargeForm, ruleCode: `RULE-${Date.now()}`, lengthFactor: coefficient, widthFactor: coefficient, heightFactor: coefficient, defaultRule: false, remark: chargeRows.map((row) => `${row.boxType}:${row.length},${row.width},${row.height};系数:${row.coefficient};${BOX_NAMES[row.boxType]}:${row.remark || '—'}`).join(';') }) + await setCustomerChargeRuleDefault(props.customerId, created.id) + await loadChargeRules() + ElMessage.success('权重系数规则已保存') + } catch (error) { ElMessage.error(getApiErrorDetails(error, '权重系数规则保存失败').message) } + finally { chargeSaving.value = false } +} + +function dimensionRule(boxType: CustomerDimensionBoxType) { return config.value?.rules.find((rule) => rule.boxType === boxType) } +function chargeDimension(boxType: CustomerDimensionBoxType) { + const row = chargeRows.find((item) => item.boxType === boxType) + if (!row) return null + return { innerLengthCm: row.length, innerWidthCm: row.width, innerHeightCm: row.height, volumeCbm: (row.length * row.width * row.height) / 1_000_000 } +} +function dimensionText(boxType: CustomerDimensionBoxType) { const rule = dimensionRule(boxType); return rule ? formatDimension(rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm) : '—' } +function volumeText(boxType: CustomerDimensionBoxType) { const rule = dimensionRule(boxType); return rule ? Number(rule.volumeCbm).toFixed(3) : '—' } +function weightedVolumeText(boxType: CustomerDimensionBoxType, coefficient: number) { const rule = dimensionRule(boxType); return rule ? (Number(rule.volumeCbm) * Number(coefficient || 1)).toFixed(3) : '—' } +function weightText(boxType: CustomerDimensionBoxType) { return chargeRows.find((row) => row.boxType === boxType)?.weight.toFixed(2) || '—' } + function formatDate(value: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '暂无记录' } -onMounted(loadConfig) +async function loadChargeRules() { + if (!canChargeView.value) return + chargeLoading.value = true + try { + chargeRules.value = await listCustomerChargeRules(props.customerId) + const remark = chargeRules.value.find((item) => item.defaultRule)?.remark || '' + const fallbackCoefficient = Number(chargeRules.value.find((item) => item.defaultRule)?.lengthFactor || 1) + chargeRows.forEach((row) => { + const match = remark.match(new RegExp(`${row.boxType}:(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?)`)) + if (match) { row.length = Number(match[1]); row.width = Number(match[2]); row.height = Number(match[3]) } + const coefficient = remark.match(new RegExp(`${row.boxType}(?::[^;]*)?;系数:(\\d+(?:\\.\\d+)?)`)) + row.coefficient = coefficient ? Number(coefficient[1]) : fallbackCoefficient + const note = remark.match(new RegExp(`${BOX_NAMES[row.boxType]}:([^;]+)`)) + if (note) row.remark = note[1] + }) + } + catch (error) { ElMessage.error(getApiErrorDetails(error, '客户计费规则加载失败').message) } + finally { chargeLoading.value = false } +} + +onMounted(() => { void loadConfig(); void loadChargeRules() }) @@ -221,8 +348,22 @@ onMounted(loadConfig) .rule-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.rule-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 13px; } .heading-actions,.dialog-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }.heading-actions :deep(.el-button),.dialog-actions :deep(.el-button) { margin-left: 0; } .rule-card { min-width: 0; }.rule-card-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }.rule-card-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.formula-band { display: flex; min-height: 52px; align-items: center; flex-wrap: wrap; gap: 12px 30px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 13px; }.formula-band strong { color: #344054; }.loading-state { min-height: 280px; } +.rule-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }.rule-title-row :deep(.el-checkbox) { height: 30px; margin-right: 0; padding: 0 10px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }.rule-title-row :deep(.el-checkbox.is-checked) { border-color: var(--cc-primary); background: var(--cc-primary-soft); } .rule-state { display: flex; min-height: 220px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #8797b2; text-align: center; }.rule-state strong { color: #475467; font-size: 13px; }.rule-state p { margin: 0; color: var(--cc-muted); font-size: 12px; } .rule-error { display: flex; min-height: 180px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px; background: #fff4f3; }.rule-error strong { color: #7a271a; }.rule-error p { margin: 5px 0; color: #b42318; font-size: 12px; } .volume-value { color: #1570ef; }.editor-content { display: grid; gap: 20px; }.editor-formula { margin: 0; padding: 13px 15px; color: #475467; font-size: 13px; line-height: 1.7; }.editor-table-wrap { overflow-x: auto; }.editor-table { width: 100%; min-width: 920px; border-collapse: collapse; }.editor-table th,.editor-table td { padding: 10px 12px; border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table thead th { background: #eef0fa; color: #475467; font-size: 12px; }.editor-table tbody th { color: var(--cc-text); font-size: 13px; }.editor-table :deep(.el-input-number),.editor-table :deep(.el-input) { width: 140px; }.editor-table :deep(.el-input-number .el-input__wrapper) { width: 100%; } +.charge-rule-card { margin-top: 18px; }.section-hint { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; font-weight: 400; }.charge-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.charge-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.charge-form :deep(.el-input-number), .charge-form :deep(.el-input) { width: 100%; } +.charge-form--simple { grid-template-columns: 1fr; }.charge-form--simple :deep(.el-form-item) { grid-column: 1; } +.charge-rule-editor-wrap { overflow-x: auto; }.charge-rule-editor { width: 100%; min-width: 920px; border-collapse: collapse; }.charge-rule-editor th,.charge-rule-editor td { padding: 9px 10px; border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor thead th { background: #eef0fa; color: #475467; font-size: 12px; }.charge-rule-editor tbody th { color: var(--cc-text); font-size: 13px; }.charge-rule-editor :deep(.el-input-number),.charge-rule-editor :deep(.el-input) { width: 118px; }.charge-rule-editor :deep(.el-input-number .el-input__wrapper) { width: 100%; } +.charge-box-cell { min-height: 34px; display: flex; align-items: center; border-bottom: 1px solid var(--cc-border); }.charge-box-cell:last-child { border-bottom: 0; } +.rule-save-row { display: flex; justify-content: flex-end; padding: 12px 0 4px; } +.dialog-actions { justify-content: center; width: 100%; } +.dialog-actions :deep(.el-button) { min-width: 112px; min-height: 46px; font-size: 15px; } +.charge-rule-card :deep(.el-table) { color: #344054; font-size: 13px; } +.charge-rule-card :deep(.el-table th.el-table__cell) { background: #f8fafc; color: #475467; font-size: 13px; font-weight: 650; } +.charge-rule-card :deep(.el-table td.el-table__cell) { color: #344054; font-size: 13px; } +.charge-rule-card :deep(.el-table .cell) { line-height: 1.5; text-align: center; } +.charge-rule-card :deep(.el-table .charge-remark-column .cell) { text-align: left; } +.charge-rule-card :deep(.el-table strong) { color: #1570ef; font-weight: 650; } @media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; }.dialog-actions { width: 100%; }.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; } } 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(() => {