提交
This commit is contained in:
@@ -2,12 +2,24 @@ import ElementPlus from 'element-plus'
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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 { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
|
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 = [
|
const boxes = [
|
||||||
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
|
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
|
||||||
@@ -17,6 +29,7 @@ const boxes = [
|
|||||||
] as const
|
] as const
|
||||||
const config: CustomerDimensionConfig = {
|
const config: CustomerDimensionConfig = {
|
||||||
tenantId: '8',
|
tenantId: '8',
|
||||||
|
defaultCalculationMode: 'ADD',
|
||||||
rules: boxes.map(([boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, remark], index) => ({
|
rules: boxes.map(([boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, remark], index) => ({
|
||||||
id: String(100 + index),
|
id: String(100 + index),
|
||||||
boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm: 2,
|
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',
|
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)
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
@@ -33,10 +53,16 @@ describe('CustomerBillingRulesPanel', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
const pinia = createPinia(); setActivePinia(pinia)
|
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()
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
vi.mocked(getCustomerDimensionConfig).mockResolvedValue(config)
|
vi.mocked(getCustomerDimensionConfig).mockResolvedValue(config)
|
||||||
vi.mocked(updateCustomerDimensionConfig).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 () => {
|
it('renders the four fixed carton dimension rows and calculated values', async () => {
|
||||||
@@ -90,4 +116,33 @@ describe('CustomerBillingRulesPanel', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(document.body.textContent).toContain('编辑箱规尺寸')
|
expect(document.body.textContent).toContain('编辑箱规尺寸')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('saves the coefficient set and can use it as the customer default calculation mode', async () => {
|
||||||
|
vi.mocked(listCustomerChargeRules)
|
||||||
|
.mockResolvedValueOnce([chargeRule])
|
||||||
|
.mockResolvedValueOnce([{ ...chargeRule, defaultRule: true }])
|
||||||
|
vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue({
|
||||||
|
...config,
|
||||||
|
defaultCalculationMode: 'MULTIPLY',
|
||||||
|
})
|
||||||
|
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('箱规按权重系数计算')
|
||||||
|
|
||||||
|
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '保存系数规则')?.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(createCustomerChargeRule).toHaveBeenCalledWith('8', expect.objectContaining({
|
||||||
|
defaultRule: false,
|
||||||
|
remark: expect.stringMatching(/SMALL:30,20,20;系数:\d+(?:\.\d+)?/),
|
||||||
|
}))
|
||||||
|
expect(setCustomerChargeRuleDefault).toHaveBeenCalledWith('8', '201')
|
||||||
|
|
||||||
|
const defaultRuleCheckboxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
|
||||||
|
defaultRuleCheckboxes[1]?.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(updateCustomerDimensionDefaultCalculationMode).toHaveBeenCalledWith('8', 'MULTIPLY')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
|
|||||||
import {
|
import {
|
||||||
getCustomerDimensionConfig,
|
getCustomerDimensionConfig,
|
||||||
updateCustomerDimensionConfig,
|
updateCustomerDimensionConfig,
|
||||||
|
updateCustomerDimensionDefaultCalculationMode,
|
||||||
type CustomerDimensionBoxType,
|
type CustomerDimensionBoxType,
|
||||||
type CustomerDimensionConfig,
|
type CustomerDimensionConfig,
|
||||||
type CustomerDimensionConfigFormModel,
|
type CustomerDimensionConfigFormModel,
|
||||||
@@ -16,19 +17,51 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.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 props = defineProps<{ customerId: string; customerName: string; customerCode?: string }>()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const canView = computed(() => access.can('firstleg:dimension-config:view'))
|
const canView = computed(() => access.can('firstleg:dimension-config:view'))
|
||||||
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
|
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
|
||||||
|
const canChargeView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
|
||||||
|
const canChargeEdit = computed(() => canChargeView.value && access.can('firstleg:fba-pricing:rule:edit'))
|
||||||
const config = ref<CustomerDimensionConfig | null>(null)
|
const config = ref<CustomerDimensionConfig | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const editorVisible = ref(false)
|
const editorVisible = ref(false)
|
||||||
const editorSaving = ref(false)
|
const editorSaving = ref(false)
|
||||||
const editorDirty = ref(false)
|
const editorDirty = ref(false)
|
||||||
|
const defaultCalculationMode = ref<'DIMENSION' | 'WEIGHT'>('DIMENSION')
|
||||||
|
const defaultModeSaving = ref(false)
|
||||||
const editorForm = ref<FormInstance>()
|
const editorForm = ref<FormInstance>()
|
||||||
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
|
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
|
||||||
|
const chargeRules = ref<CustomerChargeRule[]>([])
|
||||||
|
const visibleChargeRules = computed(() => chargeRules.value.filter((rule) => rule.status === 'ACTIVE'))
|
||||||
|
const chargeDisplayRows = computed(() => {
|
||||||
|
const rule = visibleChargeRules.value.find((item) => item.defaultRule) || visibleChargeRules.value[0]
|
||||||
|
if (!rule) return []
|
||||||
|
return BOX_ORDER.map((boxType) => ({
|
||||||
|
...rule,
|
||||||
|
boxType,
|
||||||
|
boxTypeName: BOX_NAMES[boxType],
|
||||||
|
dimension: chargeDimension(boxType),
|
||||||
|
coefficient: Number(chargeRows.find((item) => item.boxType === boxType)?.coefficient || rule.lengthFactor || 1),
|
||||||
|
weight: chargeRows.find((item) => item.boxType === boxType)?.weight || 0,
|
||||||
|
remark: chargeRows.find((item) => item.boxType === boxType)?.remark || '',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const chargeLoading = ref(false)
|
||||||
|
const chargeSaving = ref(false)
|
||||||
|
const chargeForm = reactive<CreateCustomerChargeRuleRequest>({
|
||||||
|
ruleCode: '', customerId: props.customerId, customerName: props.customerName, businessType: 'FBA', chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167,
|
||||||
|
lengthFactor: 1, widthFactor: 1, heightFactor: 1, defaultRule: false, roundingScale: 2, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom: new Date().toISOString().slice(0, 19), effectiveTo: null, remark: null,
|
||||||
|
})
|
||||||
|
const chargeRows = reactive([
|
||||||
|
{ boxType: 'SMALL' as CustomerDimensionBoxType, length: 30, width: 20, height: 20, coefficient: 1.1, weight: 5, coefficientType: '体积系数', remark: '标准小箱' },
|
||||||
|
{ boxType: 'MEDIUM' as CustomerDimensionBoxType, length: 40, width: 30, height: 30, coefficient: 1.1, weight: 8, coefficientType: '体积系数', remark: '标准中箱' },
|
||||||
|
{ boxType: 'LARGE' as CustomerDimensionBoxType, length: 50, width: 40, height: 40, coefficient: 1.1, weight: 12, coefficientType: '体积系数', remark: '标准大箱' },
|
||||||
|
{ boxType: 'EXTRA_LARGE' as CustomerDimensionBoxType, length: 60, width: 50, height: 50, coefficient: 1.1, weight: 15, coefficientType: '体积系数', remark: '特大箱' },
|
||||||
|
])
|
||||||
|
|
||||||
const BOX_ORDER: readonly CustomerDimensionBoxType[] = ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']
|
const BOX_ORDER: readonly CustomerDimensionBoxType[] = ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']
|
||||||
const BOX_NAMES: Record<CustomerDimensionBoxType, string> = {
|
const BOX_NAMES: Record<CustomerDimensionBoxType, string> = {
|
||||||
@@ -58,6 +91,7 @@ watch(form, () => {
|
|||||||
|
|
||||||
watch(() => props.customerId, () => {
|
watch(() => props.customerId, () => {
|
||||||
editorVisible.value = false
|
editorVisible.value = false
|
||||||
|
defaultCalculationMode.value = 'DIMENSION'
|
||||||
void loadConfig()
|
void loadConfig()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,6 +107,7 @@ async function loadConfig() {
|
|||||||
try {
|
try {
|
||||||
config.value = await getCustomerDimensionConfig(props.customerId)
|
config.value = await getCustomerDimensionConfig(props.customerId)
|
||||||
config.value.rules = sortRules(config.value.rules)
|
config.value.rules = sortRules(config.value.rules)
|
||||||
|
defaultCalculationMode.value = config.value.defaultCalculationMode === 'MULTIPLY' ? 'WEIGHT' : 'DIMENSION'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
config.value = null
|
config.value = null
|
||||||
loadError.value = getApiErrorDetails(error, '箱规尺寸规则加载失败,请稍后重试')
|
loadError.value = getApiErrorDetails(error, '箱规尺寸规则加载失败,请稍后重试')
|
||||||
@@ -87,6 +122,7 @@ function cloneRules(rules: CustomerDimensionRule[]) {
|
|||||||
|
|
||||||
async function openEditor() {
|
async function openEditor() {
|
||||||
if (!config.value) return
|
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)
|
form.rules = cloneRules(config.value.rules)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
editorDirty.value = false
|
editorDirty.value = false
|
||||||
@@ -157,8 +193,8 @@ async function saveConfig() {
|
|||||||
}))
|
}))
|
||||||
config.value = await updateCustomerDimensionConfig(props.customerId, { rules })
|
config.value = await updateCustomerDimensionConfig(props.customerId, { rules })
|
||||||
config.value.rules = sortRules(config.value.rules)
|
config.value.rules = sortRules(config.value.rules)
|
||||||
|
form.rules = cloneRules(config.value.rules)
|
||||||
editorDirty.value = false
|
editorDirty.value = false
|
||||||
editorVisible.value = false
|
|
||||||
ElMessage.success('箱规尺寸规则已保存')
|
ElMessage.success('箱规尺寸规则已保存')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(getApiErrorDetails(error, '箱规尺寸规则保存失败,请刷新后重试').message)
|
ElMessage.error(getApiErrorDetails(error, '箱规尺寸规则保存失败,请刷新后重试').message)
|
||||||
@@ -171,11 +207,80 @@ function formatDimension(...values: number[]) {
|
|||||||
return values.map((value) => Number(value.toFixed(2)).toString()).join(' × ')
|
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) {
|
function formatDate(value: string | null) {
|
||||||
return value ? value.replace('T', ' ').slice(0, 16) : '暂无记录'
|
return value ? value.replace('T', ' ').slice(0, 16) : '暂无记录'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadConfig)
|
async function loadChargeRules() {
|
||||||
|
if (!canChargeView.value) return
|
||||||
|
chargeLoading.value = true
|
||||||
|
try {
|
||||||
|
chargeRules.value = await listCustomerChargeRules(props.customerId)
|
||||||
|
const remark = chargeRules.value.find((item) => item.defaultRule)?.remark || ''
|
||||||
|
const fallbackCoefficient = Number(chargeRules.value.find((item) => item.defaultRule)?.lengthFactor || 1)
|
||||||
|
chargeRows.forEach((row) => {
|
||||||
|
const match = remark.match(new RegExp(`${row.boxType}:(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?)`))
|
||||||
|
if (match) { row.length = Number(match[1]); row.width = Number(match[2]); row.height = Number(match[3]) }
|
||||||
|
const coefficient = remark.match(new RegExp(`${row.boxType}(?::[^;]*)?;系数:(\\d+(?:\\.\\d+)?)`))
|
||||||
|
row.coefficient = coefficient ? Number(coefficient[1]) : fallbackCoefficient
|
||||||
|
const note = remark.match(new RegExp(`${BOX_NAMES[row.boxType]}:([^;]+)`))
|
||||||
|
if (note) row.remark = note[1]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error) { ElMessage.error(getApiErrorDetails(error, '客户计费规则加载失败').message) }
|
||||||
|
finally { chargeLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { void loadConfig(); void loadChargeRules() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -186,8 +291,14 @@ onMounted(loadConfig)
|
|||||||
<div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>请联系管理员申请尺寸计算规则查看权限。</p></div>
|
<div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>请联系管理员申请尺寸计算规则查看权限。</p></div>
|
||||||
<div v-else-if="loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
|
<div v-else-if="loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
|
||||||
<section v-else-if="config" class="rule-card">
|
<section v-else-if="config" class="rule-card">
|
||||||
<header class="rule-card-heading"><h2>按箱规尺寸计算</h2><el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button></header>
|
<header class="rule-card-heading">
|
||||||
<div class="formula-band"><span>计算方式:<strong>内径尺寸 + 外边加宽 × 2 = 外径尺寸</strong></span><span>体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</span><span>默认加边:2cm(每侧)</span></div>
|
<div class="rule-title-row">
|
||||||
|
<h2>按箱规尺寸计算</h2>
|
||||||
|
<el-checkbox :model-value="defaultCalculationMode === 'DIMENSION'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('DIMENSION')">设为默认规则</el-checkbox>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button>
|
||||||
|
</header>
|
||||||
|
<div class="formula-band"><span>计算方式:<strong>内径尺寸 + 外边加宽 × 2 = 外径尺寸</strong>(按实测尺寸匹配最小可容纳箱规)</span><span>体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</span><span>超出当前箱规时自动匹配下一档箱规</span></div>
|
||||||
<DataTable :data="config.rules" :columns="columns" :loading="loading" row-key="boxType" empty-text="当前客户暂无箱规尺寸规则">
|
<DataTable :data="config.rules" :columns="columns" :loading="loading" row-key="boxType" empty-text="当前客户暂无箱规尺寸规则">
|
||||||
<template #boxTypeName="{ row }"><strong>{{ row.boxTypeName }}</strong></template>
|
<template #boxTypeName="{ row }"><strong>{{ row.boxTypeName }}</strong></template>
|
||||||
<template #inner="{ row }">{{ formatDimension(row.innerLengthCm, row.innerWidthCm, row.innerHeightCm) }}</template>
|
<template #inner="{ row }">{{ formatDimension(row.innerLengthCm, row.innerWidthCm, row.innerHeightCm) }}</template>
|
||||||
@@ -199,18 +310,34 @@ onMounted(loadConfig)
|
|||||||
</section>
|
</section>
|
||||||
<div v-else v-loading="loading" class="loading-state" />
|
<div v-else v-loading="loading" class="loading-state" />
|
||||||
|
|
||||||
<AppDialog v-model="editorVisible" title="编辑箱规尺寸" width="min(1100px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
<section v-if="canChargeView" class="rule-card charge-rule-card">
|
||||||
|
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算</h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT')">设为默认规则</el-checkbox></div><p class="section-hint">勾选的规则将作为该客户订单“外箱规数据”的默认计算方式,同一时间只能选择一个;权重系数用于调整不同箱型的计费体积:加权体积 = 体积 × 权重系数,体积重 = 加权体积 × 167 kg/CBM。</p></div></header>
|
||||||
|
<el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置">
|
||||||
|
<el-table-column prop="boxTypeName" label="箱规类型" min-width="110" />
|
||||||
|
<el-table-column label="内径尺寸(长×宽×高 cm)" min-width="240"><template #default="{ row }">{{ row.dimension ? formatDimension(row.dimension.innerLengthCm, row.dimension.innerWidthCm, row.dimension.innerHeightCm) : '—' }}</template></el-table-column>
|
||||||
|
<el-table-column label="权重系数" min-width="110"><template #default="{ row }"><strong>{{ row.coefficient }}</strong></template></el-table-column>
|
||||||
|
<el-table-column label="体积(CBM)" min-width="120"><template #default="{ row }">{{ row.dimension ? Number(row.dimension.volumeCbm).toFixed(3) : '—' }}</template></el-table-column>
|
||||||
|
<el-table-column label="加权体积(CBM)" min-width="145"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient).toFixed(3) : '—' }}</template></el-table-column>
|
||||||
|
<el-table-column label="重量(kg)" min-width="105"><template #default="{ row }">{{ row.weight ? row.weight.toFixed(2) : '—' }}</template></el-table-column>
|
||||||
|
<el-table-column label="体积重(kg)" min-width="115"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient * 167).toFixed(2) : '—' }}</template></el-table-column>
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="180" align="left" header-align="left" class-name="charge-remark-column" label-class-name="charge-remark-column"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AppDialog v-model="editorVisible" title="编辑箱规尺寸 / 计算规则" width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
||||||
<div class="editor-content">
|
<div class="editor-content">
|
||||||
<p class="editor-formula">计算公式:外径 = 内径 + 加宽 × 2(每侧加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
<p class="editor-formula">计算公式:按长、宽、高匹配最小可容纳箱规;外径 = 该箱规内径 + 加宽 × 2(每侧加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
||||||
<el-form ref="editorForm" :model="form" label-position="top">
|
<el-form ref="editorForm" :model="form" label-position="top">
|
||||||
<div class="editor-table-wrap">
|
<div class="editor-table-wrap">
|
||||||
<table class="editor-table"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>外边加宽(cm)</th><th>备注</th></tr></thead><tbody>
|
<table class="editor-table"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>外边加宽(cm)</th><th>备注</th></tr></thead><tbody>
|
||||||
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr>
|
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr>
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rule-save-row"><el-button type="primary" :loading="editorSaving" @click="saveConfig"><Save :size="16" />保存规则</el-button></div>
|
||||||
|
<section v-if="canChargeView" class="combined-charge-editor"><h3>箱规按权重系数计算</h3><p class="editor-formula">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="charge-rule-editor"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th>{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr></tbody></table></div><div class="rule-save-row"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig"><Save :size="16" />保存系数规则</el-button></div></section>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="editorSaving" @click="saveConfig"><Save :size="16" />保存规则</el-button></div></template>
|
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -221,8 +348,22 @@ onMounted(loadConfig)
|
|||||||
.rule-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.rule-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 13px; }
|
.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; }
|
.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-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-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; }
|
.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%; }
|
.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; } }
|
@media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; }.dialog-actions { width: 100%; }.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type {
|
import type {
|
||||||
CustomerDimensionConfig,
|
CustomerDimensionConfig,
|
||||||
|
CustomerDimensionCalculationMode,
|
||||||
CustomerDimensionRule,
|
CustomerDimensionRule,
|
||||||
UpdateCustomerDimensionConfigRequest,
|
UpdateCustomerDimensionConfigRequest,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -23,6 +24,7 @@ function normalizeRule(rule: RawRule): CustomerDimensionRule {
|
|||||||
function normalizeConfig(config: RawConfig): CustomerDimensionConfig {
|
function normalizeConfig(config: RawConfig): CustomerDimensionConfig {
|
||||||
return {
|
return {
|
||||||
tenantId: String(config.tenantId),
|
tenantId: String(config.tenantId),
|
||||||
|
defaultCalculationMode: config.defaultCalculationMode === 'MULTIPLY' ? 'MULTIPLY' : 'ADD',
|
||||||
rules: config.rules.map(normalizeRule),
|
rules: config.rules.map(normalizeRule),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,3 +45,15 @@ export async function updateCustomerDimensionConfig(
|
|||||||
})
|
})
|
||||||
return normalizeConfig(unwrapApiResponse(response))
|
return normalizeConfig(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCustomerDimensionDefaultCalculationMode(
|
||||||
|
tenantId: string,
|
||||||
|
defaultCalculationMode: CustomerDimensionCalculationMode,
|
||||||
|
): Promise<CustomerDimensionConfig> {
|
||||||
|
const response = await http.put<never, ApiEnvelope<RawConfig>>(
|
||||||
|
`${dimensionConfigPath}/default-calculation-mode`,
|
||||||
|
{ defaultCalculationMode },
|
||||||
|
{ params: { tenantId } },
|
||||||
|
)
|
||||||
|
return normalizeConfig(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export {
|
export {
|
||||||
getCustomerDimensionConfig,
|
getCustomerDimensionConfig,
|
||||||
updateCustomerDimensionConfig,
|
updateCustomerDimensionConfig,
|
||||||
|
updateCustomerDimensionDefaultCalculationMode,
|
||||||
} from './api'
|
} from './api'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CustomerDimensionBoxType,
|
CustomerDimensionBoxType,
|
||||||
|
CustomerDimensionCalculationMode,
|
||||||
CustomerDimensionConfig,
|
CustomerDimensionConfig,
|
||||||
CustomerDimensionConfigFormModel,
|
CustomerDimensionConfigFormModel,
|
||||||
CustomerDimensionRule,
|
CustomerDimensionRule,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type CustomerDimensionBoxType = 'SMALL' | 'MEDIUM' | 'LARGE' | 'EXTRA_LARGE'
|
export type CustomerDimensionBoxType = 'SMALL' | 'MEDIUM' | 'LARGE' | 'EXTRA_LARGE'
|
||||||
|
export type CustomerDimensionCalculationMode = 'ADD' | 'MULTIPLY'
|
||||||
|
|
||||||
export interface CustomerDimensionRule {
|
export interface CustomerDimensionRule {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,6 +21,7 @@ export interface CustomerDimensionRule {
|
|||||||
|
|
||||||
export interface CustomerDimensionConfig {
|
export interface CustomerDimensionConfig {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
|
defaultCalculationMode: CustomerDimensionCalculationMode
|
||||||
rules: CustomerDimensionRule[]
|
rules: CustomerDimensionRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export interface CustomerChargeRule {
|
||||||
|
id: string
|
||||||
|
tenantId: string
|
||||||
|
ruleCode: string
|
||||||
|
ruleVersion: number
|
||||||
|
customerId: string
|
||||||
|
customerName: string
|
||||||
|
businessType: string
|
||||||
|
chargeMode: 'KG' | 'CBM'
|
||||||
|
taxMode: 'TAX_INCLUDED' | 'TAX_EXCLUDED'
|
||||||
|
conversionFactor: number
|
||||||
|
lengthFactor: number
|
||||||
|
widthFactor: number
|
||||||
|
heightFactor: number
|
||||||
|
defaultRule: boolean
|
||||||
|
roundingScale: number
|
||||||
|
roundingMode: string
|
||||||
|
currency: string
|
||||||
|
effectiveFrom: string
|
||||||
|
effectiveTo: string | null
|
||||||
|
status: 'ACTIVE' | 'INACTIVE'
|
||||||
|
remark: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateCustomerChargeRuleRequest = Omit<CustomerChargeRule, 'id' | 'tenantId' | 'ruleVersion' | 'status'> & {
|
||||||
|
id?: never
|
||||||
|
tenantId?: never
|
||||||
|
ruleVersion?: never
|
||||||
|
status?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = '/platform/v1/fba-pricing/customer-rules'
|
||||||
|
|
||||||
|
function normalize(value: CustomerChargeRule): CustomerChargeRule {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
id: String(value.id), tenantId: String(value.tenantId), customerId: String(value.customerId),
|
||||||
|
ruleVersion: Number(value.ruleVersion), conversionFactor: Number(value.conversionFactor),
|
||||||
|
lengthFactor: Number(value.lengthFactor ?? 1), widthFactor: Number(value.widthFactor ?? 1),
|
||||||
|
heightFactor: Number(value.heightFactor ?? 1), defaultRule: Boolean(value.defaultRule),
|
||||||
|
roundingScale: Number(value.roundingScale),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomerChargeRules(customerId: string, tenantId?: string) {
|
||||||
|
const response = await http.get<never, ApiEnvelope<CustomerChargeRule[]>>(path, {
|
||||||
|
params: { tenantId: tenantId || customerId, customerId },
|
||||||
|
})
|
||||||
|
return unwrapApiResponse(response).map(normalize)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCustomerChargeRule(tenantId: string, request: CreateCustomerChargeRuleRequest) {
|
||||||
|
const response = await http.post<never, ApiEnvelope<CustomerChargeRule>>(`${path}/versions`, request, { params: { tenantId } })
|
||||||
|
return normalize(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCustomerChargeRuleDefault(tenantId: string, ruleId: string) {
|
||||||
|
await http.put<never, ApiEnvelope<void>>(`${path}/${encodeURIComponent(ruleId)}/default`, undefined, { params: { tenantId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retireCustomerChargeRule(tenantId: string, ruleId: string) {
|
||||||
|
await http.delete<never, ApiEnvelope<void>>(`${path}/${encodeURIComponent(ruleId)}`, { params: { tenantId } })
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './public'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
createCustomerChargeRule,
|
||||||
|
listCustomerChargeRules,
|
||||||
|
retireCustomerChargeRule,
|
||||||
|
setCustomerChargeRuleDefault,
|
||||||
|
} from './customer-rules/api'
|
||||||
|
export type { CustomerChargeRule, CreateCustomerChargeRuleRequest } from './customer-rules/api'
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@/shared/api/http'
|
} from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
adjustPublishedInboundFee,
|
adjustPublishedInboundFee,
|
||||||
|
calculateInboundFee,
|
||||||
confirmInboundFee,
|
confirmInboundFee,
|
||||||
createFeeActionIdempotencyKey,
|
createFeeActionIdempotencyKey,
|
||||||
createFeeAdjustmentIdempotencyKey,
|
createFeeAdjustmentIdempotencyKey,
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
sendInboundFee,
|
sendInboundFee,
|
||||||
} from './api'
|
} from './api'
|
||||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||||
|
import { getInboundMeasurement } from '../measurement/api'
|
||||||
|
import type { InboundMeasurementDetail } from '../measurement/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
customerId?: string
|
customerId?: string
|
||||||
@@ -44,6 +47,10 @@ const adjustmentError = ref<ApiErrorDetails | null>(null)
|
|||||||
const conflictRefreshed = ref(false)
|
const conflictRefreshed = ref(false)
|
||||||
const snapshot = ref<InboundFeeSnapshot | null>(null)
|
const snapshot = ref<InboundFeeSnapshot | null>(null)
|
||||||
const adjustOpen = ref(false)
|
const adjustOpen = ref(false)
|
||||||
|
const trialOpen = ref(false)
|
||||||
|
const trialLoading = ref(false)
|
||||||
|
const measurement = ref<InboundMeasurementDetail | null>(null)
|
||||||
|
const trial = reactive({ weightSource: 'ACTUAL', volumeSource: 'ACTUAL' })
|
||||||
const adjustFormRef = ref<FormInstance>()
|
const adjustFormRef = ref<FormInstance>()
|
||||||
let loadSequence = 0
|
let loadSequence = 0
|
||||||
let adjustmentFingerprint = ''
|
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 canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
|
||||||
const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
|
const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
|
||||||
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
|
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 customerContextId = computed(() => props.customerId || props.tenantId || '')
|
||||||
const normalizedInboundId = computed(() => {
|
const normalizedInboundId = computed(() => {
|
||||||
const identifier = String(props.inboundId ?? '').trim()
|
const identifier = String(props.inboundId ?? '').trim()
|
||||||
@@ -83,6 +91,18 @@ const adjustmentEligible = computed(() => (
|
|||||||
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
|
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
|
||||||
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
|
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
|
||||||
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
|
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) {
|
function feeStatusInfo(status?: string) {
|
||||||
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
|
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
|
||||||
@@ -177,6 +197,38 @@ function openAdjustment() {
|
|||||||
adjustOpen.value = true
|
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') {
|
async function transitionFee(action: 'confirm' | 'send') {
|
||||||
if (!snapshot.value || feeAction.value) return
|
if (!snapshot.value || feeAction.value) return
|
||||||
const confirming = action === 'confirm'
|
const confirming = action === 'confirm'
|
||||||
@@ -299,6 +351,13 @@ defineExpose({ reload: loadFee })
|
|||||||
@click="loadFee()"
|
@click="loadFee()"
|
||||||
><RefreshCw :size="16" /></el-button>
|
><RefreshCw :size="16" /></el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
|
<el-button
|
||||||
|
v-if="canCalculate"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:loading="trialLoading"
|
||||||
|
@click="openTrial"
|
||||||
|
><CircleDollarSign :size="16" />费用试算</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canAdjust && adjustmentEligible"
|
v-if="canAdjust && adjustmentEligible"
|
||||||
data-testid="adjust-fee-button"
|
data-testid="adjust-fee-button"
|
||||||
@@ -313,6 +372,13 @@ defineExpose({ reload: loadFee })
|
|||||||
:disabled="Boolean(feeAction)"
|
:disabled="Boolean(feeAction)"
|
||||||
@click="transitionFee('confirm')"
|
@click="transitionFee('confirm')"
|
||||||
><Check :size="16" />确认费用</el-button>
|
><Check :size="16" />确认费用</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canConfirm && canSend && snapshot?.feeStatus === 'CALCULATED'"
|
||||||
|
type="primary"
|
||||||
|
:loading="Boolean(feeAction)"
|
||||||
|
:disabled="Boolean(feeAction)"
|
||||||
|
@click="confirmAndPush"
|
||||||
|
><Send :size="16" />确认推送费用</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canSend && snapshot?.feeStatus === 'CONFIRMED'"
|
v-if="canSend && snapshot?.feeStatus === 'CONFIRMED'"
|
||||||
data-testid="send-fee-button"
|
data-testid="send-fee-button"
|
||||||
@@ -324,6 +390,14 @@ defineExpose({ reload: loadFee })
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<AppDialog v-model="trialOpen" title="头程费用试算" width="min(760px, calc(100vw - 28px))" :confirm-loading="trialLoading" confirm-text="开始试算" @confirm="submitTrial">
|
||||||
|
<el-alert title="重量和体积各选择一项;默认选择实际测量数据。" type="info" :closable="false" show-icon />
|
||||||
|
<div class="trial-source-grid">
|
||||||
|
<section><h4>计费重量(单选)</h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
|
||||||
|
<section><h4>计费体积(单选)</h4><el-radio-group v-model="trial.volumeSource"><el-radio v-for="item in sourceOptions" :key="`volume-${item.value}`" :value="item.value" :disabled="item.volume <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.volume, 6) }} CBM</strong></el-radio></el-radio-group></section>
|
||||||
|
</div>
|
||||||
|
</AppDialog>
|
||||||
|
|
||||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||||
<template #title>当前账号没有查看入库费用的权限</template>
|
<template #title>当前账号没有查看入库费用的权限</template>
|
||||||
<p>需要权限:firstleg:fba-pricing:view</p>
|
<p>需要权限:firstleg:fba-pricing:view</p>
|
||||||
@@ -539,6 +613,7 @@ defineExpose({ reload: loadFee })
|
|||||||
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
|
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
|
||||||
.adjustment-total strong { justify-self: center; font-size: 18px; white-space: nowrap; }
|
.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; }
|
.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; }
|
.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) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export async function getLatestInboundFee(tenantId: string, inboundId: string) {
|
|||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function calculateInboundFee(tenantId: string, inboundId: string, payload: { selectedWeightKg: number; selectedVolumeCbm: number; weightSource: string; volumeSource: string }, idempotencyKey: string) {
|
||||||
|
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||||
|
`${feeBasePath(inboundId)}/calculate`, payload,
|
||||||
|
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
async function transitionInboundFee(
|
async function transitionInboundFee(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
inboundId: string,
|
inboundId: string,
|
||||||
@@ -55,7 +63,7 @@ export function createFeeAdjustmentIdempotencyKey() {
|
|||||||
return createFeeActionIdempotencyKey('adjust')
|
return createFeeActionIdempotencyKey('adjust')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFeeActionIdempotencyKey(action: 'adjust' | 'confirm' | 'send') {
|
export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'confirm' | 'send') {
|
||||||
const uuid = globalThis.crypto?.randomUUID?.()
|
const uuid = globalThis.crypto?.randomUUID?.()
|
||||||
if (uuid) return `fee-${action}-${uuid}`
|
if (uuid) return `fee-${action}-${uuid}`
|
||||||
return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||||
|
|||||||
@@ -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 InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
|
||||||
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
|
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
|
||||||
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
|
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
|
||||||
export { default as InboundFeePanel } from './fee/InboundFeePanel.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 { downloadWeightImage } from './weight/api/weightOcr'
|
||||||
export type { InboundMeasurementDetail } from './measurement/types'
|
export type { InboundMeasurementDetail } from './measurement/types'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const props = defineProps<{
|
|||||||
customerId: string
|
customerId: string
|
||||||
inboundId: string
|
inboundId: string
|
||||||
inboundCode?: string | null
|
inboundCode?: string | null
|
||||||
|
openWarehouseSuggestion?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -84,6 +85,7 @@ function handleSubmitted() {
|
|||||||
ref="measurementPanelRef"
|
ref="measurementPanelRef"
|
||||||
:customer-id="customerId"
|
:customer-id="customerId"
|
||||||
:inbound-id="inboundId"
|
:inbound-id="inboundId"
|
||||||
|
:open-warehouse-suggestion="openWarehouseSuggestion"
|
||||||
@updated="handleUpdated"
|
@updated="handleUpdated"
|
||||||
@saved="handleUpdated"
|
@saved="handleUpdated"
|
||||||
@submitted="handleSubmitted"
|
@submitted="handleSubmitted"
|
||||||
|
|||||||
@@ -277,6 +277,22 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect(payload).not.toHaveProperty('originalVolumeCbm')
|
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 () => {
|
it('updates the server weight summary without discarding unsaved measurement edits', async () => {
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
const countInput = wrapper.find('.measurement-count-editor input')
|
const countInput = wrapper.find('.measurement-count-editor input')
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { AlertTriangle, Image, Plus, Save, Send, Upload } from '@lucide/vue'
|
|||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
|
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
|
||||||
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
|
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
|
||||||
|
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
|
||||||
import type {
|
import type {
|
||||||
InboundMeasurementDetail,
|
InboundMeasurementDetail,
|
||||||
InboundMeasurementItem,
|
InboundMeasurementItem,
|
||||||
@@ -19,6 +21,7 @@ const props = defineProps<{
|
|||||||
customerId?: string
|
customerId?: string
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
inboundId: string
|
inboundId: string
|
||||||
|
openWarehouseSuggestion?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -28,10 +31,12 @@ const emit = defineEmits<{
|
|||||||
submitted: []
|
submitted: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
|
type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coefficientMode'> & {
|
||||||
itemId: string
|
itemId: string
|
||||||
clientKey: string
|
clientKey: string
|
||||||
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
|
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
|
||||||
|
chargeRuleId: string | null
|
||||||
|
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeasurementFormModel {
|
interface MeasurementFormModel {
|
||||||
@@ -57,10 +62,15 @@ const loading = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const manualEntryOpen = 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 manualEntrySubmitting = ref(false)
|
||||||
const manualEntryImage = ref<File | null>(null)
|
const manualEntryImage = ref<File | null>(null)
|
||||||
const manualEntryErrors = ref<string[]>([])
|
const manualEntryErrors = ref<string[]>([])
|
||||||
const detail = ref<InboundMeasurementDetail | null>(null)
|
const detail = ref<InboundMeasurementDetail | null>(null)
|
||||||
|
const chargeRules = ref<CustomerChargeRule[]>([])
|
||||||
|
const chargeRulesLoading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const operationError = ref<ApiErrorDetails | null>(null)
|
const operationError = ref<ApiErrorDetails | null>(null)
|
||||||
const conflictDetected = ref(false)
|
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 canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
|
||||||
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
|
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
|
||||||
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
|
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 customerId = computed(() => props.customerId || props.tenantId || '')
|
||||||
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
|
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
|
||||||
const countEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
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 registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
|
||||||
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
|
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 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({
|
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||||
key: 'p2-inbound-measurement-editor',
|
key: 'p2-inbound-measurement-editor',
|
||||||
@@ -203,6 +217,7 @@ async function submitManualEntry() {
|
|||||||
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
||||||
return {
|
return {
|
||||||
itemId: String(item.id),
|
itemId: String(item.id),
|
||||||
|
inboundDetailId: String(item.inboundDetailId || item.id),
|
||||||
clientKey: nextItemKey(),
|
clientKey: nextItemKey(),
|
||||||
boxCode: item.boxCode,
|
boxCode: item.boxCode,
|
||||||
itemNo: item.itemNo,
|
itemNo: item.itemNo,
|
||||||
@@ -227,9 +242,41 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
|||||||
totalVolumeCbm: item.totalVolumeCbm,
|
totalVolumeCbm: item.totalVolumeCbm,
|
||||||
volumeWeightKg: item.volumeWeightKg,
|
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() {
|
function clearForm() {
|
||||||
form.measurementId = null
|
form.measurementId = null
|
||||||
form.version = 0
|
form.version = 0
|
||||||
@@ -261,6 +308,11 @@ async function loadMeasurement() {
|
|||||||
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
|
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
|
||||||
if (sequence !== requestSequence) return
|
if (sequence !== requestSequence) return
|
||||||
applyDetail(resource)
|
applyDetail(resource)
|
||||||
|
void loadChargeRules()
|
||||||
|
if (props.openWarehouseSuggestion && !warehouseSuggestionAutoOpened.value && form.items.length) {
|
||||||
|
warehouseSuggestionAutoOpened.value = true
|
||||||
|
openWarehouseSuggestion()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (sequence !== requestSequence) return
|
if (sequence !== requestSequence) return
|
||||||
detail.value = null
|
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 (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 位小数`)
|
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 (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
|
||||||
})
|
})
|
||||||
if (general.length) errors.general = general
|
if (general.length) errors.general = general
|
||||||
@@ -348,7 +408,16 @@ async function saveDraft() {
|
|||||||
conflictDetected.value = false
|
conflictDetected.value = false
|
||||||
try {
|
try {
|
||||||
const originalCounts = new Map((detail.value?.items || []).map((item) => [String(item.id), item.packageCount]))
|
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)
|
const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount)
|
||||||
let version = form.version
|
let version = form.version
|
||||||
if (draftChanged) {
|
if (draftChanged) {
|
||||||
@@ -356,7 +425,14 @@ async function saveDraft() {
|
|||||||
measurementId: form.measurementId,
|
measurementId: form.measurementId,
|
||||||
version,
|
version,
|
||||||
actualGoodsName: form.actualGoodsName.trim() || null,
|
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
|
version = saved.version
|
||||||
}
|
}
|
||||||
@@ -461,6 +537,17 @@ function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
|
|||||||
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
|
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(
|
watch(
|
||||||
() => [customerId.value, props.inboundId, canView.value] as const,
|
() => [customerId.value, props.inboundId, canView.value] as const,
|
||||||
() => {
|
() => {
|
||||||
@@ -470,6 +557,7 @@ watch(
|
|||||||
operationError.value = null
|
operationError.value = null
|
||||||
conflictDetected.value = false
|
conflictDetected.value = false
|
||||||
validationErrors.value = {}
|
validationErrors.value = {}
|
||||||
|
warehouseSuggestionAutoOpened.value = false
|
||||||
clearForm()
|
clearForm()
|
||||||
if (hasContext.value && canView.value) void loadMeasurement()
|
if (hasContext.value && canView.value) void loadMeasurement()
|
||||||
},
|
},
|
||||||
@@ -567,7 +655,10 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
<section class="measurement-section measurement-items-section">
|
<section class="measurement-section measurement-items-section">
|
||||||
<div class="measurement-section__heading">
|
<div class="measurement-section__heading">
|
||||||
<div><h3>箱规明细</h3></div>
|
<div><h3>箱规明细</h3></div>
|
||||||
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
|
<div class="measurement-section-actions">
|
||||||
|
<el-button v-if="canWarehouseSuggestion" type="primary" plain :disabled="!mayMaintain || !form.items.length" @click="openWarehouseSuggestion"><Plus :size="15" />添加仓库建议数据</el-button>
|
||||||
|
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
@@ -591,13 +682,22 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
</dl>
|
</dl>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column type="index" label="#" width="48" align="center" />
|
<el-table-column prop="itemNo" label="#" width="70" align="center" />
|
||||||
<el-table-column label="箱号" min-width="156" show-overflow-tooltip>
|
<el-table-column label="箱号" min-width="156" show-overflow-tooltip>
|
||||||
<template #default="{ row }"><span class="measurement-box-code">{{ row.boxCode || '—' }}</span></template>
|
<template #default="{ row }"><span class="measurement-box-code">{{ row.boxCode || '—' }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
|
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
|
||||||
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="仓库修改外箱(长)" width="165" align="center">
|
||||||
|
<template #default="{ row }"><el-input-number v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="仓库修改外箱(宽)" width="165" align="center">
|
||||||
|
<template #default="{ row }"><el-input-number v-model="row.outerWidthCm" aria-label="仓库修改外箱宽" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="仓库修改外箱(高)" width="165" align="center">
|
||||||
|
<template #default="{ row }"><el-input-number v-model="row.outerHeightCm" aria-label="仓库修改外箱高" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="确认箱数" width="132" align="center">
|
<el-table-column label="确认箱数" width="132" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -612,6 +712,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="计算方式" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select v-model="row.coefficientMode" placeholder="选择计算方式">
|
||||||
|
<el-option label="实测原尺寸(不调整)" value="ACTUAL" />
|
||||||
|
<el-option label="按箱规尺寸策略" value="ADD" />
|
||||||
|
<el-option label="按箱规尺寸系数" value="MULTIPLY" :disabled="!chargeRules.some((rule) => rule.defaultRule && rule.status === 'ACTIVE')" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="总体积(m³)" width="108" align="right">
|
<el-table-column label="总体积(m³)" width="108" align="right">
|
||||||
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
|
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -626,6 +735,18 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-empty v-else description="暂无扫描箱测量数据" />
|
<el-empty v-else description="暂无扫描箱测量数据" />
|
||||||
|
<div v-if="form.items.length" class="warehouse-suggestion-table">
|
||||||
|
<h3>仓库建议数据</h3>
|
||||||
|
<el-table :data="form.items" border stripe empty-text="暂无仓库建议数据">
|
||||||
|
<el-table-column prop="itemNo" label="当前实测 SKU" min-width="150" />
|
||||||
|
<el-table-column label="外箱规 长×宽×高(cm)" min-width="190"><template #default="{ row }">{{ formatNumber(row.outerLengthCm) }} × {{ formatNumber(row.outerWidthCm) }} × {{ formatNumber(row.outerHeightCm) }}</template></el-table-column>
|
||||||
|
<el-table-column prop="packageCount" label="箱数" width="90" />
|
||||||
|
<el-table-column label="周长(cm)" width="110"><template #default="{ row }">{{ formatNumber(row.serverValues.girthCm) }}</template></el-table-column>
|
||||||
|
<el-table-column label="总体积(m³)" width="120"><template #default="{ row }">{{ formatNumber(row.serverValues.unitVolumeCbm == null ? null : row.serverValues.unitVolumeCbm * row.packageCount, 6) }}</template></el-table-column>
|
||||||
|
<el-table-column label="计算策略" min-width="190"><template #default="{ row }">{{ row.coefficientMode === 'MULTIPLY' ? '按箱规尺寸系数' : '按箱规尺寸策略' }}<span v-if="row.coefficientMode === 'MULTIPLY'">({{ chargeRuleName(row.chargeRuleId) }})</span></template></el-table-column>
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
@@ -639,6 +760,21 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<AppDialog v-model="warehouseSuggestionOpen" title="添加仓库建议数据" width="min(680px, calc(100vw - 28px))" confirm-text="加入建议" @confirm="applyWarehouseSuggestion">
|
||||||
|
<el-form label-position="top" class="warehouse-suggestion-form">
|
||||||
|
<el-form-item label="当前实测 SKU" required><el-select v-model="warehouseSuggestion.itemId" filterable><el-option v-for="item in form.items" :key="item.itemId" :label="`${item.itemNo} · ${item.boxSpecification || item.boxCode || '实测SKU'}`" :value="item.itemId" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="箱数" required><el-input-number v-model="warehouseSuggestion.packageCount" :min="1" :max="1000000" step-strictly /></el-form-item>
|
||||||
|
<el-form-item label="计算方式"><el-select v-model="warehouseSuggestion.coefficientMode"><el-option label="按箱规尺寸策略" value="ADD" /><el-option label="按箱规尺寸系数" value="MULTIPLY" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="warehouseSuggestion.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<dl v-if="suggestionItem" class="suggestion-preview">
|
||||||
|
<div><dt>长 × 宽 × 高</dt><dd>{{ formatNumber(suggestionItem.lengthCm) }} × {{ formatNumber(suggestionItem.widthCm) }} × {{ formatNumber(suggestionItem.heightCm) }} cm</dd></div>
|
||||||
|
<div><dt>箱规周长</dt><dd>{{ formatNumber(suggestionGirth) }} cm</dd></div>
|
||||||
|
<div><dt>单箱体积</dt><dd>{{ formatNumber(suggestionItem.serverValues.unitVolumeCbm, 6) }} m³</dd></div>
|
||||||
|
<div><dt>总体积</dt><dd>{{ formatNumber(suggestionTotalVolume, 6) }} m³</dd></div>
|
||||||
|
</dl>
|
||||||
|
</AppDialog>
|
||||||
|
|
||||||
<el-dialog v-model="imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview">
|
<el-dialog v-model="imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview">
|
||||||
<div class="measurement-image-preview">
|
<div class="measurement-image-preview">
|
||||||
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
|
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
|
||||||
@@ -717,6 +853,8 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
.measurement-declaration h4 { margin: 0 0 10px; color: #344054; font-size: 12px; font-weight: 700; }
|
.measurement-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 { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||||
.measurement-section__heading > div { min-width: 0; }
|
.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-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-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; }
|
.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(.el-table__cell) { padding: 7px 0; }
|
||||||
.measurement-table :deep(.cell) { line-height: 1.35; }
|
.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); }
|
.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-box-code { font-weight: 650; }
|
||||||
.measurement-count-editor { width: 104px !important; }
|
.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; }
|
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 20px; margin: 0; padding: 4px 18px 8px 62px; }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type InboundMeasurementProcessStatus =
|
|||||||
|
|
||||||
export interface InboundMeasurementItem {
|
export interface InboundMeasurementItem {
|
||||||
id: MeasurementIdentifier
|
id: MeasurementIdentifier
|
||||||
|
inboundDetailId?: MeasurementIdentifier
|
||||||
boxCode: string | null
|
boxCode: string | null
|
||||||
itemNo: string
|
itemNo: string
|
||||||
boxSpecification: string
|
boxSpecification: string
|
||||||
@@ -33,6 +34,8 @@ export interface InboundMeasurementItem {
|
|||||||
outerLengthCm: number | null
|
outerLengthCm: number | null
|
||||||
outerWidthCm: number | null
|
outerWidthCm: number | null
|
||||||
outerHeightCm: number | null
|
outerHeightCm: number | null
|
||||||
|
chargeRuleId?: MeasurementIdentifier | null
|
||||||
|
coefficientMode?: 'ADD' | 'MULTIPLY' | null
|
||||||
remark: string | null
|
remark: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,21 +96,24 @@ export interface InboundMeasurementDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveInboundMeasurementItemRequest {
|
export interface SaveInboundMeasurementItemRequest {
|
||||||
boxCode: string | null
|
inboundDetailId?: MeasurementIdentifier
|
||||||
|
boxCode?: string | null
|
||||||
itemNo: string
|
itemNo: string
|
||||||
boxSpecification: string
|
boxSpecification: string
|
||||||
packageCount: number
|
packageCount: number
|
||||||
lengthCm: number
|
lengthCm: number
|
||||||
widthCm: number
|
widthCm: number
|
||||||
heightCm: number
|
heightCm: number
|
||||||
unitWeightKg: number
|
unitWeightKg?: number
|
||||||
intelligentImageFileId: MeasurementIdentifier | null
|
intelligentImageFileId: MeasurementIdentifier | null
|
||||||
machineNo: string | null
|
machineNo?: string | null
|
||||||
measuredAt: string | null
|
measuredAt?: string | null
|
||||||
overLimitWarning: string | null
|
overLimitWarning: string | null
|
||||||
outerLengthCm: number | null
|
outerLengthCm: number | null
|
||||||
outerWidthCm: number | null
|
outerWidthCm: number | null
|
||||||
outerHeightCm: number | null
|
outerHeightCm: number | null
|
||||||
|
chargeRuleId?: MeasurementIdentifier | null
|
||||||
|
coefficientMode?: 'ADD' | 'MULTIPLY' | null
|
||||||
remark: string | null
|
remark: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,14 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
expect(source).toContain('<ChevronUp v-if="!measurementCollapsed.customer"')
|
expect(source).toContain('<ChevronUp v-if="!measurementCollapsed.customer"')
|
||||||
expect(source).toContain('<ChevronDown v-else :size="18" />')
|
expect(source).toContain('<ChevronDown v-else :size="18" />')
|
||||||
expect(source).toContain("{ key: 'girthCm', label: '箱规周长(cm)' }")
|
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('runMoreAction($event, row as UnifiedOrderResource)')
|
||||||
expect(source).toContain(':aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
|
expect(source).toContain(':aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
|
||||||
expect(source).toContain(':title="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))')
|
expect(source).not.toContain('formatNumber(2 * (width + height))')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the matched dimension addition and enabled multiplier in calculation choices', () => {
|
||||||
|
expect(source).toContain('getCustomerDimensionConfig')
|
||||||
|
expect(source).toContain('matchingDimensionRule')
|
||||||
|
expect(source).toContain('`原尺寸 + ${values[0]}cm`')
|
||||||
|
expect(source).toContain('`原尺寸 × ${rule.lengthFactor}`')
|
||||||
|
expect(source).toContain('label="箱规尺寸" value="ADD"')
|
||||||
|
expect(source).toContain('label="箱规系数" value="MULTIPLY"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the three section calculation modes independent and recalculates their display rows', () => {
|
||||||
|
expect(source).toContain("measurementSectionModes = reactive<Record<MeasurementSection, MeasurementCalculationMode>>")
|
||||||
|
expect(source).toContain(':model-value="measurementSectionModes.customer"')
|
||||||
|
expect(source).toContain(':model-value="measurementSectionModes.outer"')
|
||||||
|
expect(source).toContain(':model-value="measurementSectionModes.actual"')
|
||||||
|
expect(source).toContain("updateMeasurementSectionMode('customer', $event)")
|
||||||
|
expect(source).toContain("updateMeasurementSectionMode('outer', $event)")
|
||||||
|
expect(source).toContain("updateMeasurementSectionMode('actual', $event)")
|
||||||
|
expect(source).toContain('calculatedMeasurementRow(row, measurementSectionModes.customer, true)')
|
||||||
|
expect(source).toContain('}, measurementSectionModes.outer)')
|
||||||
|
expect(source).toContain('calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual)')
|
||||||
|
expect(source).toContain('rawDimensions.map((value) => value + rule.edgeAdditionCm)')
|
||||||
|
expect(source).toContain('matchedSpecificationCoefficient(chargeRule, calculationSource)')
|
||||||
|
expect(source).toContain('listCustomerChargeRules(customerId, tenantId)')
|
||||||
|
expect(source).toContain('matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))')
|
||||||
|
expect(source).not.toContain('[chargeRule.lengthFactor, chargeRule.widthFactor, chargeRule.heightFactor]')
|
||||||
|
expect(source).not.toContain('column.key === \'coefficientMode\'')
|
||||||
|
expect(source).not.toContain('@change="updateMeasurementCalculationMode(null, $event)"')
|
||||||
|
})
|
||||||
|
|
||||||
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
|
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
|
||||||
expect(source).toContain('<el-table :data="measurementDetail?.items || []" row-key="id"')
|
expect(source).toContain('<el-table :data="actualMeasurementRows" row-key="id"')
|
||||||
expect(source).toContain('<el-table-column label="箱号" prop="boxCode"')
|
expect(source).toContain('<el-table-column label="箱号" prop="boxCode"')
|
||||||
expect(source).toContain("row.boxCode || '—'")
|
expect(source).toContain("row.boxCode || '—'")
|
||||||
expect(source).toContain("`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`")
|
expect(source).toContain("`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`")
|
||||||
@@ -444,9 +481,10 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
|
|
||||||
it('matches the prototype table grid and distinguishes container identifiers', () => {
|
it('matches the prototype table grid and distinguishes container identifiers', () => {
|
||||||
expect(source).toContain('row-key="orderId" border stripe')
|
expect(source).toContain('row-key="orderId" border stripe')
|
||||||
expect(source).toContain('<el-table :data="detail.order.cargoes" border stripe')
|
expect(source).toContain('<el-table :data="customerMeasurementRows" border stripe')
|
||||||
expect(source.match(/<el-table :data="measurementDetail\?\.items \|\| \[\]"[^>]*border stripe/g)).toHaveLength(2)
|
expect(source).toContain('<el-table :data="outerMeasurementRows" border stripe')
|
||||||
expect(source).toContain('<el-table :data="detail.order.cargoes" border stripe empty-text="暂无客户下单数据"')
|
expect(source).toContain('<el-table :data="actualMeasurementRows" row-key="id" border stripe')
|
||||||
|
expect(source).toContain('<el-table :data="customerMeasurementRows" border stripe empty-text="暂无客户下单数据"')
|
||||||
expect(source).toContain('--el-table-border-color: #e5e7eb')
|
expect(source).toContain('--el-table-border-color: #e5e7eb')
|
||||||
expect(source).toContain('--el-table-row-hover-bg-color: #f3f7fb')
|
expect(source).toContain('--el-table-row-hover-bg-color: #f3f7fb')
|
||||||
expect(source).toContain('.el-table--striped .el-table__body tr.el-table__row--striped')
|
expect(source).toContain('.el-table--striped .el-table__body tr.el-table__row--striped')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
getPlatformUnifiedOrder,
|
getPlatformUnifiedOrder,
|
||||||
@@ -57,12 +57,20 @@ import {
|
|||||||
} from '@/modules/unified-orders/status'
|
} from '@/modules/unified-orders/status'
|
||||||
import {
|
import {
|
||||||
downloadWeightImage,
|
downloadWeightImage,
|
||||||
|
calculateInboundFee,
|
||||||
|
confirmInboundFee,
|
||||||
|
createFeeActionIdempotencyKey,
|
||||||
getInboundMeasurement,
|
getInboundMeasurement,
|
||||||
|
saveInboundMeasurementDraft,
|
||||||
|
getLatestInboundFee,
|
||||||
InboundFeePanel,
|
InboundFeePanel,
|
||||||
InboundMeasurementDialog,
|
InboundMeasurementDialog,
|
||||||
MeasurementEvidenceThumbnail,
|
MeasurementEvidenceThumbnail,
|
||||||
|
sendInboundFee,
|
||||||
type InboundMeasurementDetail,
|
type InboundMeasurementDetail,
|
||||||
} from '@/modules/inbounds'
|
} from '@/modules/inbounds'
|
||||||
|
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
|
||||||
|
import { getCustomerDimensionConfig, type CustomerDimensionConfig, type CustomerDimensionRule } from '@/modules/dimension-config'
|
||||||
import {
|
import {
|
||||||
OrderBasicInfoIcon,
|
OrderBasicInfoIcon,
|
||||||
OrderCargoInfoIcon,
|
OrderCargoInfoIcon,
|
||||||
@@ -151,14 +159,44 @@ const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
|||||||
const measurementLoading = ref(false)
|
const measurementLoading = ref(false)
|
||||||
const measurementError = ref<ApiErrorDetails | null>(null)
|
const measurementError = ref<ApiErrorDetails | null>(null)
|
||||||
const measurementLoadedKey = ref('')
|
const measurementLoadedKey = ref('')
|
||||||
|
const measurementChargeRules = ref<CustomerChargeRule[]>([])
|
||||||
|
const measurementChargeRulesLoading = ref(false)
|
||||||
|
const measurementChargeRulesLoadedKey = ref('')
|
||||||
|
const measurementDimensionConfig = ref<CustomerDimensionConfig | null>(null)
|
||||||
|
const measurementDimensionConfigLoading = ref(false)
|
||||||
|
const measurementDimensionConfigLoadedKey = ref('')
|
||||||
|
const measurementStrategySavingId = ref('')
|
||||||
|
const measurementOuterSavingId = ref('')
|
||||||
const measurementPreviewOpen = ref(false)
|
const measurementPreviewOpen = ref(false)
|
||||||
const measurementPreviewLoading = ref(false)
|
const measurementPreviewLoading = ref(false)
|
||||||
const measurementPreviewError = ref<ApiErrorDetails | null>(null)
|
const measurementPreviewError = ref<ApiErrorDetails | null>(null)
|
||||||
const measurementPreviewUrl = ref('')
|
const measurementPreviewUrl = ref('')
|
||||||
const measurementPreviewTitle = ref('测量图片')
|
const measurementPreviewTitle = ref('测量图片')
|
||||||
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
|
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
|
||||||
|
type MeasurementSection = keyof typeof measurementCollapsed
|
||||||
|
type MeasurementCalculationMode = 'ACTUAL' | 'ADD' | 'MULTIPLY'
|
||||||
|
const measurementSectionModes = reactive<Record<MeasurementSection, MeasurementCalculationMode>>({
|
||||||
|
customer: 'ACTUAL', outer: 'ACTUAL', actual: 'ACTUAL',
|
||||||
|
})
|
||||||
const cargoImagePreviewUrls = new Set<string>()
|
const cargoImagePreviewUrls = new Set<string>()
|
||||||
const measurementDialogOpen = ref(false)
|
const measurementDialogOpen = ref(false)
|
||||||
|
const openWarehouseSuggestionOnMeasurementLoad = ref(false)
|
||||||
|
const confirmFeeSending = ref(false)
|
||||||
|
const feeConfirmOpen = ref(false)
|
||||||
|
const feeConfirmWeight = ref<number | null>(null)
|
||||||
|
const feeConfirmVolume = ref<number | null>(null)
|
||||||
|
const feeConfirmRows = computed(() => {
|
||||||
|
const summary = detail.value?.summary
|
||||||
|
const measurement = summary?.measurement
|
||||||
|
const actualWeight = Number(measurement?.actualGrossWeightKg)
|
||||||
|
const actualVolume = Number(measurement?.actualVolumeCbm)
|
||||||
|
const customerTotals = measurementTotals(customerMeasurementRows.value)
|
||||||
|
return [
|
||||||
|
{ source: '客户下单数据', count: summary?.totalPackageCount, weight: customerTotals.gross, volume: customerTotals.volume ?? summary?.expectedVolumeCbm },
|
||||||
|
{ source: '外箱规数据', count: measurementTotals(outerMeasurementRows.value).count, weight: measurementTotals(outerMeasurementRows.value).gross, volume: measurementTotals(outerMeasurementRows.value).volume },
|
||||||
|
{ source: '实际测量数据', count: measurementDetail.value?.actualPackageCount, weight: actualWeight, volume: actualVolume },
|
||||||
|
]
|
||||||
|
})
|
||||||
const smartLoadingOpen = ref(false)
|
const smartLoadingOpen = ref(false)
|
||||||
const measurementOrder = ref<UnifiedOrderResource | null>(null)
|
const measurementOrder = ref<UnifiedOrderResource | null>(null)
|
||||||
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
|
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
|
||||||
@@ -213,8 +251,140 @@ const customerMeasurementColumns = [
|
|||||||
]
|
]
|
||||||
const measuredColumns = [
|
const measuredColumns = [
|
||||||
...customerMeasurementColumns.slice(0, 7), { key: 'girthCm', label: '箱规周长(cm)' }, { key: 'totalWeightKg', label: '总毛重(T.K.GW)' },
|
...customerMeasurementColumns.slice(0, 7), { key: 'girthCm', label: '箱规周长(cm)' }, { key: 'totalWeightKg', label: '总毛重(T.K.GW)' },
|
||||||
{ key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' }, { key: 'remark', label: '备注' },
|
{ key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' },
|
||||||
|
{ key: 'remark', label: '备注' },
|
||||||
]
|
]
|
||||||
|
const outerColumns = measuredColumns
|
||||||
|
const actualMeasuredColumns = measuredColumns.filter((column) => column.key !== 'itemNo')
|
||||||
|
|
||||||
|
function sortedDimensions(row: Record<string, unknown>) {
|
||||||
|
const values = [Number(row.lengthCm), Number(row.widthCm), Number(row.heightCm)]
|
||||||
|
return values.every((value) => Number.isFinite(value) && value > 0)
|
||||||
|
? values.sort((left, right) => right - left)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchingDimensionRule(row: Record<string, unknown>): CustomerDimensionRule | null {
|
||||||
|
const measured = sortedDimensions(row)
|
||||||
|
if (!measured) return null
|
||||||
|
return [...(measurementDimensionConfig.value?.rules || [])]
|
||||||
|
.sort((left, right) => (
|
||||||
|
left.innerLengthCm * left.innerWidthCm * left.innerHeightCm
|
||||||
|
- right.innerLengthCm * right.innerWidthCm * right.innerHeightCm
|
||||||
|
))
|
||||||
|
.find((rule) => {
|
||||||
|
const inner = [rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm].sort((left, right) => right - left)
|
||||||
|
return measured.every((value, index) => value <= inner[index])
|
||||||
|
}) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCoefficientLabel(row?: Record<string, unknown>) {
|
||||||
|
const rows = row ? [row] : (measurementDetail.value?.items || []) as unknown as Record<string, unknown>[]
|
||||||
|
const values = [...new Set(rows.map((item) => matchingDimensionRule(item)?.edgeAdditionCm).filter((value) => value != null))]
|
||||||
|
if (values.length === 1) return `原尺寸 + ${values[0]}cm`
|
||||||
|
if (measurementDimensionConfigLoading.value) return '原尺寸 + 加载中…'
|
||||||
|
return values.length > 1 ? '原尺寸 + 按箱规匹配' : '原尺寸 + 未匹配箱规'
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplyCoefficientLabel() {
|
||||||
|
const rule = measurementChargeRules.value.find((item) => item.defaultRule && item.status === 'ACTIVE')
|
||||||
|
if (rule) return `原尺寸 × ${rule.lengthFactor}`
|
||||||
|
return measurementChargeRulesLoading.value ? '原尺寸 × 加载中…' : '原尺寸 × 未启用系数'
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchedSpecificationCoefficient(rule: CustomerChargeRule, row: Record<string, unknown>) {
|
||||||
|
const tiers = [...(rule.remark || '').matchAll(
|
||||||
|
/(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+(?:\.\d+)?);系数:(\d+(?:\.\d+)?)/g,
|
||||||
|
)].map((match) => ({
|
||||||
|
dimensions: [Number(match[2]), Number(match[3]), Number(match[4])].sort((left, right) => right - left),
|
||||||
|
coefficient: Number(match[5]),
|
||||||
|
})).filter((tier) => tier.dimensions.every((value) => Number.isFinite(value) && value > 0)
|
||||||
|
&& Number.isFinite(tier.coefficient) && tier.coefficient > 0)
|
||||||
|
.sort((left, right) => left.dimensions.reduce((total, value) => total * value, 1)
|
||||||
|
- right.dimensions.reduce((total, value) => total * value, 1))
|
||||||
|
if (!tiers.length) return Number(rule.lengthFactor || 1)
|
||||||
|
const measured = [Number(row.lengthCm), Number(row.widthCm), Number(row.heightCm)]
|
||||||
|
.sort((left, right) => right - left)
|
||||||
|
const matched = tiers.find((tier) => measured.every((value, index) => value <= tier.dimensions[index]))
|
||||||
|
return (matched || tiers[tiers.length - 1]).coefficient
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMeasurementChargeRule = computed(() => measurementChargeRules.value
|
||||||
|
.find((rule) => rule.defaultRule && rule.status === 'ACTIVE') || null)
|
||||||
|
const multiplyModeDisabled = computed(() => !measurementChargeRulesLoading.value && !activeMeasurementChargeRule.value)
|
||||||
|
|
||||||
|
function roundedUp(value: number, digits: number) {
|
||||||
|
const factor = 10 ** digits
|
||||||
|
return Math.ceil((value - Number.EPSILON) * factor) / factor
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatedMeasurementRow(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
mode: MeasurementCalculationMode,
|
||||||
|
customerSource = false,
|
||||||
|
) {
|
||||||
|
const calculationSource = !customerSource && mode !== 'ACTUAL'
|
||||||
|
? {
|
||||||
|
...source,
|
||||||
|
lengthCm: source.outerLengthCm ?? source.lengthCm,
|
||||||
|
widthCm: source.outerWidthCm ?? source.widthCm,
|
||||||
|
heightCm: source.outerHeightCm ?? source.heightCm,
|
||||||
|
}
|
||||||
|
: source
|
||||||
|
const rule = matchingDimensionRule(calculationSource)
|
||||||
|
const rawDimensions = [Number(calculationSource.lengthCm), Number(calculationSource.widthCm), Number(calculationSource.heightCm)]
|
||||||
|
if (!rawDimensions.every((value) => Number.isFinite(value) && value > 0)) return { ...source }
|
||||||
|
|
||||||
|
const matchedDimensions = mode === 'ADD' && rule
|
||||||
|
? rawDimensions.map((value) => value + rule.edgeAdditionCm)
|
||||||
|
: rawDimensions
|
||||||
|
const chargeRule = activeMeasurementChargeRule.value
|
||||||
|
const specificationCoefficient = mode === 'MULTIPLY' && chargeRule
|
||||||
|
? matchedSpecificationCoefficient(chargeRule, calculationSource)
|
||||||
|
: 1
|
||||||
|
const [lengthCm, widthCm, heightCm] = matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))
|
||||||
|
const packageCount = Number(source.packageCount)
|
||||||
|
const unitVolumeCbm = mode === 'ADD' && rule
|
||||||
|
? Math.round((lengthCm * widthCm * heightCm / 1_000_000) * 1000) / 1000
|
||||||
|
: roundedUp(lengthCm * widthCm * heightCm / 1_000_000, 4)
|
||||||
|
const totalVolumeCbm = Number.isFinite(packageCount) ? roundedUp(unitVolumeCbm * packageCount, 4) : null
|
||||||
|
const sourceUnitWeight = customerSource
|
||||||
|
? Number(source.grossWeightKg) / packageCount
|
||||||
|
: Number(source.unitWeightKg)
|
||||||
|
const unitWeightKg = Number.isFinite(sourceUnitWeight) ? sourceUnitWeight : null
|
||||||
|
const totalWeightKg = unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
lengthCm, widthCm, heightCm, unitWeightKg, unitVolumeCbm, totalWeightKg, totalVolumeCbm,
|
||||||
|
girthCm: roundedUp(lengthCm + 2 * (widthCm + heightCm), 3),
|
||||||
|
volumeWeightKg: totalVolumeCbm == null ? null : roundedUp(totalVolumeCbm * 167, 3),
|
||||||
|
remark: customerSource ? source.cargoRemark : source.remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerMeasurementRows = computed(() => (detail.value?.order.cargoes || []).map((row) => (
|
||||||
|
calculatedMeasurementRow(row, measurementSectionModes.customer, true)
|
||||||
|
)))
|
||||||
|
const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => {
|
||||||
|
const source = row as unknown as Record<string, unknown>
|
||||||
|
return calculatedMeasurementRow({
|
||||||
|
...source,
|
||||||
|
lengthCm: source.outerLengthCm ?? source.lengthCm,
|
||||||
|
widthCm: source.outerWidthCm ?? source.widthCm,
|
||||||
|
heightCm: source.outerHeightCm ?? source.heightCm,
|
||||||
|
}, measurementSectionModes.outer)
|
||||||
|
}))
|
||||||
|
const actualMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => (
|
||||||
|
calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual)
|
||||||
|
)))
|
||||||
|
|
||||||
|
function updateMeasurementSectionMode(section: MeasurementSection, mode: MeasurementCalculationMode) {
|
||||||
|
if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) {
|
||||||
|
ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
measurementSectionModes[section] = mode
|
||||||
|
}
|
||||||
|
|
||||||
const statusTabs = computed(() => [
|
const statusTabs = computed(() => [
|
||||||
{ value: '' as const, label: '全部' },
|
{ value: '' as const, label: '全部' },
|
||||||
@@ -529,7 +699,12 @@ function feeReviewLabel(value: string | null | undefined) {
|
|||||||
return unifiedOrderReviewStatusLabel(value)
|
return unifiedOrderReviewStatusLabel(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false) {
|
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false, outerSource = false) {
|
||||||
|
if (key === 'coefficientMode') return measurementCalculationLabel(row)
|
||||||
|
if (outerSource && ['lengthCm', 'widthCm', 'heightCm'].includes(key)) {
|
||||||
|
const outerValue = row[`outer${key[0].toUpperCase()}${key.slice(1)}`]
|
||||||
|
return outerValue == null ? '—' : formatNumber(Number(outerValue), 6)
|
||||||
|
}
|
||||||
if (customerSource && key === 'unitWeightKg') {
|
if (customerSource && key === 'unitWeightKg') {
|
||||||
const total = Number(row.grossWeightKg)
|
const total = Number(row.grossWeightKg)
|
||||||
const count = Number(row.packageCount)
|
const count = Number(row.packageCount)
|
||||||
@@ -546,6 +721,191 @@ function measurementValue(row: Record<string, unknown>, key: string, customerSou
|
|||||||
return typeof value === 'number' ? formatNumber(value, 6) : String(value)
|
return typeof value === 'number' ? formatNumber(value, 6) : String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function measurementCalculationLabel(row: Record<string, unknown>) {
|
||||||
|
if (measurementCalculationModeForRow(row) === 'MULTIPLY') {
|
||||||
|
return `乘以${measurementChargeRuleName(row.chargeRuleId)}`
|
||||||
|
}
|
||||||
|
if (measurementCalculationModeForRow(row) === 'ACTUAL') return '实测原尺寸(不调整)'
|
||||||
|
return addCoefficientLabel(row).replace('原尺寸 + ', '加')
|
||||||
|
}
|
||||||
|
|
||||||
|
function measurementCalculationModeForRow(row: Record<string, unknown>): MeasurementCalculationMode {
|
||||||
|
if (row.coefficientMode === 'MULTIPLY') return 'MULTIPLY'
|
||||||
|
if (row.coefficientMode === 'ADD') return 'ADD'
|
||||||
|
const itemNo = normalizedIdentifier(row.itemNo)
|
||||||
|
const measuredItem = measurementDetail.value?.items.find((item) => normalizedIdentifier(item.itemNo) === itemNo)
|
||||||
|
return measuredItem?.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : measuredItem?.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMeasurementCalculationMode(row: Record<string, unknown> | null, mode: Exclude<MeasurementCalculationMode, 'ACTUAL'>) {
|
||||||
|
const summary = detail.value?.summary
|
||||||
|
const measurement = measurementDetail.value
|
||||||
|
if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null || !can('platform:firstleg:inbound:measure:edit')) return
|
||||||
|
const itemId = normalizedIdentifier(row?.id)
|
||||||
|
const itemNo = normalizedIdentifier(row?.itemNo)
|
||||||
|
const targetIds = new Set(measurement.items
|
||||||
|
.filter((item) => !row || (itemId ? String(item.id) === itemId : normalizedIdentifier(item.itemNo) === itemNo))
|
||||||
|
.map((item) => String(item.id)))
|
||||||
|
if (!targetIds.size) {
|
||||||
|
ElMessage.warning('当前数据尚未关联实测明细,暂不能设置计算方式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
measurementStrategySavingId.value = row ? (itemId || `item-${itemNo}`) : 'all'
|
||||||
|
try {
|
||||||
|
await Promise.all([loadMeasurementChargeRules(), loadMeasurementDimensionConfig()])
|
||||||
|
const activeRule = measurementChargeRules.value.find((rule) => rule.defaultRule && rule.status === 'ACTIVE')
|
||||||
|
const requiresMultiply = measurement.items.some((item) => (
|
||||||
|
targetIds.has(String(item.id)) ? mode : item.coefficientMode
|
||||||
|
) === 'MULTIPLY')
|
||||||
|
if (requiresMultiply && !activeRule) {
|
||||||
|
ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), {
|
||||||
|
measurementId: String(measurement.measurementId),
|
||||||
|
version: measurement.version,
|
||||||
|
actualGoodsName: measurement.actualGoodsName,
|
||||||
|
items: measurement.items.map((item) => ({
|
||||||
|
inboundDetailId: item.inboundDetailId,
|
||||||
|
boxCode: item.boxCode,
|
||||||
|
itemNo: item.itemNo,
|
||||||
|
boxSpecification: item.boxSpecification,
|
||||||
|
packageCount: item.packageCount,
|
||||||
|
lengthCm: item.lengthCm,
|
||||||
|
widthCm: item.widthCm,
|
||||||
|
heightCm: item.heightCm,
|
||||||
|
unitWeightKg: item.unitWeightKg,
|
||||||
|
intelligentImageFileId: item.intelligentImageFileId,
|
||||||
|
machineNo: item.machineNo,
|
||||||
|
measuredAt: item.deviceMeasuredAt,
|
||||||
|
overLimitWarning: item.overLimitWarning,
|
||||||
|
outerLengthCm: item.outerLengthCm,
|
||||||
|
outerWidthCm: item.outerWidthCm,
|
||||||
|
outerHeightCm: item.outerHeightCm,
|
||||||
|
chargeRuleId: (targetIds.has(String(item.id)) ? mode : item.coefficientMode) === 'MULTIPLY'
|
||||||
|
? activeRule!.id
|
||||||
|
: null,
|
||||||
|
coefficientMode: targetIds.has(String(item.id)) ? mode : item.coefficientMode || 'ADD',
|
||||||
|
remark: item.remark,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
measurementLoadedKey.value = ''
|
||||||
|
await loadMeasurementDetails()
|
||||||
|
ElMessage.success(`${row ? '当前数据' : '全部测量数据'}的计算方式已更新,体积已重新计算`)
|
||||||
|
} catch (error) {
|
||||||
|
measurementLoadedKey.value = ''
|
||||||
|
await loadMeasurementDetails()
|
||||||
|
ElMessage.error(getApiErrorDetails(error, '计算方式更新失败').message)
|
||||||
|
} finally {
|
||||||
|
measurementStrategySavingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWarehouseOuterDimensions(row: Record<string, unknown>) {
|
||||||
|
const summary = detail.value?.summary
|
||||||
|
const measurement = measurementDetail.value
|
||||||
|
const editableMeasurementStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'])
|
||||||
|
const currentStatus = editableMeasurementStatuses.has(measurement?.processStatus || '')
|
||||||
|
? measurement!.processStatus
|
||||||
|
: summary?.mainStatus || measurement?.processStatus || ''
|
||||||
|
if (measurement && !editableMeasurementStatuses.has(currentStatus)) {
|
||||||
|
const statusLabel = orderStatusLabel(summary?.mainStatus) || measurementStatusLabel(currentStatus)
|
||||||
|
ElMessage.warning(`当前状态为“${statusLabel}”,该状态不可以修改测量数据`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null
|
||||||
|
|| !can('platform:firstleg:inbound:measure:edit')) return
|
||||||
|
const dimensions = [row.outerLengthCm, row.outerWidthCm, row.outerHeightCm].map(Number)
|
||||||
|
if (dimensions.some((value) => !Number.isFinite(value) || value <= 0)) {
|
||||||
|
ElMessage.warning('仓库修改外箱长、宽、高必须同时填写且大于 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
measurementOuterSavingId.value = String(row.id)
|
||||||
|
try {
|
||||||
|
await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), {
|
||||||
|
measurementId: String(measurement.measurementId),
|
||||||
|
version: measurement.version,
|
||||||
|
actualGoodsName: measurement.actualGoodsName,
|
||||||
|
items: measurement.items.map((item) => ({
|
||||||
|
inboundDetailId: item.inboundDetailId,
|
||||||
|
boxCode: item.boxCode,
|
||||||
|
itemNo: item.itemNo,
|
||||||
|
boxSpecification: item.boxSpecification,
|
||||||
|
packageCount: item.packageCount,
|
||||||
|
lengthCm: item.lengthCm,
|
||||||
|
widthCm: item.widthCm,
|
||||||
|
heightCm: item.heightCm,
|
||||||
|
unitWeightKg: item.unitWeightKg,
|
||||||
|
intelligentImageFileId: item.intelligentImageFileId,
|
||||||
|
machineNo: item.machineNo,
|
||||||
|
measuredAt: item.deviceMeasuredAt,
|
||||||
|
overLimitWarning: item.overLimitWarning,
|
||||||
|
outerLengthCm: String(item.id) === String(row.id) ? dimensions[0] : item.outerLengthCm,
|
||||||
|
outerWidthCm: String(item.id) === String(row.id) ? dimensions[1] : item.outerWidthCm,
|
||||||
|
outerHeightCm: String(item.id) === String(row.id) ? dimensions[2] : item.outerHeightCm,
|
||||||
|
chargeRuleId: item.chargeRuleId,
|
||||||
|
coefficientMode: item.coefficientMode || 'ADD',
|
||||||
|
remark: item.remark,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
measurementLoadedKey.value = ''
|
||||||
|
await loadMeasurementDetails()
|
||||||
|
ElMessage.success(`序号 ${row.itemNo || '—'} 的外箱规已更新,相关数值已重新计算`)
|
||||||
|
} catch (error) {
|
||||||
|
measurementLoadedKey.value = ''
|
||||||
|
await loadMeasurementDetails()
|
||||||
|
ElMessage.error(getApiErrorDetails(error, '仓库外箱尺寸保存失败').message)
|
||||||
|
} finally {
|
||||||
|
measurementOuterSavingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function measurementChargeRuleName(ruleId: unknown) {
|
||||||
|
const normalizedRuleId = normalizedIdentifier(ruleId)
|
||||||
|
const selected = normalizedRuleId
|
||||||
|
? measurementChargeRules.value.find((rule) => rule.id === normalizedRuleId)
|
||||||
|
: null
|
||||||
|
const fallback = measurementChargeRules.value.find((rule) => rule.defaultRule && rule.status === 'ACTIVE')
|
||||||
|
|| measurementChargeRules.value.find((rule) => rule.status === 'ACTIVE')
|
||||||
|
const rule = selected || fallback
|
||||||
|
if (rule) {
|
||||||
|
const remark = rule.remark?.trim()
|
||||||
|
return `系数 ${rule.lengthFactor}${remark ? ` · ${remark}` : ''}${rule.defaultRule ? '(默认)' : ''}`
|
||||||
|
}
|
||||||
|
if (measurementChargeRulesLoading.value) return '加载中…'
|
||||||
|
return normalizedRuleId || '未配置'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMeasurementChargeRules() {
|
||||||
|
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
|
||||||
|
const customerId = normalizedIdentifier(detail.value?.summary.customerId) || tenantId
|
||||||
|
const loadedKey = `${tenantId}:${customerId}`
|
||||||
|
if (!customerId || measurementChargeRulesLoadedKey.value === loadedKey || measurementChargeRulesLoading.value) return
|
||||||
|
measurementChargeRulesLoading.value = true
|
||||||
|
try {
|
||||||
|
measurementChargeRules.value = await listCustomerChargeRules(customerId, tenantId)
|
||||||
|
measurementChargeRulesLoadedKey.value = loadedKey
|
||||||
|
} catch {
|
||||||
|
measurementChargeRules.value = []
|
||||||
|
} finally {
|
||||||
|
measurementChargeRulesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMeasurementDimensionConfig() {
|
||||||
|
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
|
||||||
|
if (!tenantId || measurementDimensionConfigLoadedKey.value === tenantId || measurementDimensionConfigLoading.value) return
|
||||||
|
measurementDimensionConfigLoading.value = true
|
||||||
|
try {
|
||||||
|
measurementDimensionConfig.value = await getCustomerDimensionConfig(tenantId)
|
||||||
|
measurementDimensionConfigLoadedKey.value = tenantId
|
||||||
|
} catch {
|
||||||
|
measurementDimensionConfig.value = null
|
||||||
|
} finally {
|
||||||
|
measurementDimensionConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface MeasurementTotals {
|
interface MeasurementTotals {
|
||||||
count: number
|
count: number
|
||||||
gross: number
|
gross: number
|
||||||
@@ -608,8 +968,16 @@ async function loadMeasurementDetails() {
|
|||||||
measurementLoading.value = true
|
measurementLoading.value = true
|
||||||
measurementError.value = null
|
measurementError.value = null
|
||||||
try {
|
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
|
if (serial !== measurementRequestSerial) return
|
||||||
|
const configuredMode = measurementDimensionConfig.value?.defaultCalculationMode
|
||||||
|
measurementSectionModes.outer = configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value
|
||||||
|
? 'MULTIPLY'
|
||||||
|
: 'ADD'
|
||||||
measurementDetail.value = response
|
measurementDetail.value = response
|
||||||
measurementLoadedKey.value = key
|
measurementLoadedKey.value = key
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -837,6 +1205,64 @@ function openMeasurement(row: UnifiedOrderResource) {
|
|||||||
measurementDialogOpen.value = true
|
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[]) {
|
function handleOrderSelectionChange(rows: UnifiedOrderResource[]) {
|
||||||
selectedOrders.value = rows
|
selectedOrders.value = rows
|
||||||
selectedApprovalOrders.value = rows.filter(canApprove)
|
selectedApprovalOrders.value = rows.filter(canApprove)
|
||||||
@@ -1021,6 +1447,7 @@ function handleMeasurementSubmitted() {
|
|||||||
|
|
||||||
function clearMeasurementOrder() {
|
function clearMeasurementOrder() {
|
||||||
measurementOrder.value = null
|
measurementOrder.value = null
|
||||||
|
openWarehouseSuggestionOnMeasurementLoad.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
|
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
|
||||||
@@ -1163,6 +1590,17 @@ async function loadDetail(force = false) {
|
|||||||
measurementDetail.value = null
|
measurementDetail.value = null
|
||||||
measurementError.value = null
|
measurementError.value = null
|
||||||
measurementLoadedKey.value = ''
|
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
|
measurementPreviewOpen.value = false
|
||||||
releaseMeasurementPreview()
|
releaseMeasurementPreview()
|
||||||
releaseCargoImagePreviews()
|
releaseCargoImagePreviews()
|
||||||
@@ -1226,6 +1664,22 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
<section class="business-page unified-orders-page">
|
<section class="business-page unified-orders-page">
|
||||||
|
<AppDialog v-model="feeConfirmOpen" title="编辑计费数据" confirm-text="确认" :confirm-loading="confirmFeeSending" width="min(920px, 94vw)" :teleported="false" @confirm="executeConfirmAndSendFee">
|
||||||
|
<el-form label-position="top" class="fee-confirm-form">
|
||||||
|
<el-form-item label="入仓码"><el-input :model-value="detail?.summary.inboundCode || '—'" disabled /></el-form-item>
|
||||||
|
<el-form-item label="渠道"><el-input :model-value="detail?.summary.channelName || detail?.summary.channelCode || '—'" disabled /></el-form-item>
|
||||||
|
<el-form-item label="计费毛重"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" class="fee-confirm-number" /><span class="fee-confirm-unit">kg</span></div></el-form-item>
|
||||||
|
<el-form-item label="计费体积"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" class="fee-confirm-number" /><span class="fee-confirm-unit">m³</span></div></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<h4 class="fee-confirm-reference-title">参考数据</h4>
|
||||||
|
<el-table :data="feeConfirmRows" border size="small">
|
||||||
|
<el-table-column prop="source" label="数据来源" />
|
||||||
|
<el-table-column prop="count" label="件数" />
|
||||||
|
<el-table-column label="总毛重(T.K.GW)"><template #default="{ row }">{{ row.weight == null ? '—' : formatNumber(row.weight, 2) }}</template></el-table-column>
|
||||||
|
<el-table-column label="总体积(m³)"><template #default="{ row }">{{ row.volume == null ? '—' : formatNumber(row.volume, 4) }}</template></el-table-column>
|
||||||
|
<el-table-column label="总材积重"><template #default="{ row }">{{ row.volume == null ? '—' : formatNumber(Number(row.volume) * 167, 2) }}</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</AppDialog>
|
||||||
<template v-if="isDetailRoute">
|
<template v-if="isDetailRoute">
|
||||||
<DetailPageLayout ariaLabel="统一订单详情">
|
<DetailPageLayout ariaLabel="统一订单详情">
|
||||||
<header class="order-detail-header">
|
<header class="order-detail-header">
|
||||||
@@ -1420,7 +1874,19 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="activeDetailTab === 'measurement'">
|
<template v-else-if="activeDetailTab === 'measurement'">
|
||||||
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
|
<div class="panel-title">
|
||||||
|
<div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div>
|
||||||
|
<div class="measurement-title-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="can('firstleg:inbound:confirm-send-fee')"
|
||||||
|
type="primary"
|
||||||
|
:loading="confirmFeeSending"
|
||||||
|
:title="detail.summary.measurement?.reviewStatus === 'APPROVED' ? '确认并发送费用' : '测量审核通过后才能发送费用'"
|
||||||
|
data-testid="confirm-send-fee-button"
|
||||||
|
@click="openFeeConfirmDialog"
|
||||||
|
><Send :size="15" />确认费用发送</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<el-alert v-if="!can('platform:firstleg:inbound:measure:view')" title="当前账号缺少测量查看权限" type="warning" :closable="false" show-icon />
|
<el-alert v-if="!can('platform:firstleg:inbound:measure:view')" title="当前账号缺少测量查看权限" type="warning" :closable="false" show-icon />
|
||||||
<LoadErrorState v-else-if="measurementError" title="测量数据暂时无法加载" :message="measurementError.message" :loading="measurementLoading" @retry="loadMeasurementDetails">
|
<LoadErrorState v-else-if="measurementError" title="测量数据暂时无法加载" :message="measurementError.message" :loading="measurementLoading" @retry="loadMeasurementDetails">
|
||||||
<template #details><ApiErrorMeta :details="measurementError" /></template>
|
<template #details><ApiErrorMeta :details="measurementError" /></template>
|
||||||
@@ -1428,28 +1894,50 @@ onBeforeUnmount(() => {
|
|||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="measurement-section">
|
<section class="measurement-section">
|
||||||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button>
|
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(customerMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.customer" aria-label="客户下单数据计算方式" @change="updateMeasurementSectionMode('customer', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
||||||
<div v-if="!measurementCollapsed.customer">
|
<div v-if="!measurementCollapsed.customer">
|
||||||
<div class="detail-table-wrap"><el-table :data="detail.order.cargoes" border stripe empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key, true)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key, true) }}</template></template></el-table-column></el-table></div>
|
<div class="detail-table-wrap"><el-table :data="customerMeasurementRows" border stripe empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
|
||||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span></div>
|
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="measurement-section">
|
<section class="measurement-section">
|
||||||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button>
|
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(outerMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.outer" aria-label="外箱规数据计算方式" @change="updateMeasurementSectionMode('outer', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
||||||
<div v-if="!measurementCollapsed.outer">
|
<div v-if="!measurementCollapsed.outer">
|
||||||
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" border stripe empty-text="暂无外箱规数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
|
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="outerMeasurementRows" border stripe empty-text="暂无外箱规数据"><el-table-column label="#" prop="itemNo" min-width="70" /><el-table-column v-for="column in outerColumns.filter((item) => item.key !== 'itemNo')" :key="column.key" :label="column.label" :prop="column.key" min-width="150"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
|
||||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span></div>
|
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<div class="measurement-rule-selector">
|
||||||
|
<span class="measurement-rule-selector__label">箱规计算规则选择</span>
|
||||||
|
<el-select class="measurement-rule-selector__control" :model-value="measurementSectionModes.outer" aria-label="外箱规数据计算方式" @change="updateMeasurementSectionMode('outer', $event)">
|
||||||
|
<el-option label="实测原尺寸(不调整)" value="ACTUAL" />
|
||||||
|
<el-option label="箱规尺寸" value="ADD" />
|
||||||
|
<el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" />
|
||||||
|
</el-select>
|
||||||
|
<span class="measurement-rule-selector__hint">规则参数来源:客户管理</span>
|
||||||
|
</div>
|
||||||
<section class="measurement-section">
|
<section class="measurement-section">
|
||||||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积重:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span><span>重量体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button>
|
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.actual" aria-label="实际测量数据计算方式" @change="updateMeasurementSectionMode('actual', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
||||||
<div v-if="!measurementCollapsed.actual">
|
<div v-if="!measurementCollapsed.actual">
|
||||||
<div v-loading="measurementLoading" class="detail-table-wrap">
|
<div v-loading="measurementLoading" class="detail-table-wrap">
|
||||||
<el-table :data="measurementDetail?.items || []" row-key="id" border stripe empty-text="暂无实际测量数据">
|
<el-table :data="actualMeasurementRows" row-key="id" border stripe empty-text="暂无实际测量数据">
|
||||||
<el-table-column label="箱号" prop="boxCode" min-width="190" fixed="left" show-overflow-tooltip>
|
<el-table-column label="箱号" prop="boxCode" min-width="190" fixed="left" show-overflow-tooltip>
|
||||||
<template #default="{ row }"><strong class="measurement-box-code">{{ row.boxCode || '—' }}</strong></template>
|
<template #default="{ row }"><strong class="measurement-box-code">{{ row.boxCode || '—' }}</strong></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120">
|
<el-table-column label="#" prop="itemNo" min-width="70" fixed="left" />
|
||||||
|
<el-table-column label="仓库修改外箱(长)" min-width="160">
|
||||||
|
<template #default="{ row }"><el-input-number v-if="can('platform:firstleg:inbound:measure:edit')" v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="请输入" /><template v-else>{{ formatNumber(row.outerLengthCm) }}</template></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="仓库修改外箱(宽)" min-width="160">
|
||||||
|
<template #default="{ row }"><el-input-number v-if="can('platform:firstleg:inbound:measure:edit')" v-model="row.outerWidthCm" aria-label="仓库修改外箱宽" :min="0.001" :precision="3" :controls="false" placeholder="请输入" /><template v-else>{{ formatNumber(row.outerWidthCm) }}</template></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="仓库修改外箱(高)" min-width="160">
|
||||||
|
<template #default="{ row }"><el-input-number v-if="can('platform:firstleg:inbound:measure:edit')" v-model="row.outerHeightCm" aria-label="仓库修改外箱高" :min="0.001" :precision="3" :controls="false" placeholder="请输入" /><template v-else>{{ formatNumber(row.outerHeightCm) }}</template></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="can('platform:firstleg:inbound:measure:edit')" label="仓库外箱操作" min-width="120">
|
||||||
|
<template #default="{ row }"><el-button type="primary" plain size="small" :loading="measurementOuterSavingId === String(row.id)" :disabled="Boolean(measurementOuterSavingId)" @click="saveWarehouseOuterDimensions(row as Record<string, unknown>)">保存外箱</el-button></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-for="column in actualMeasuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120">
|
||||||
<template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template>
|
<template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="尺寸图片" min-width="148">
|
<el-table-column label="尺寸图片" min-width="148">
|
||||||
@@ -1466,7 +1954,7 @@ onBeforeUnmount(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span></div>
|
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span></div>
|
||||||
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
|
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
|
||||||
<div class="weight-evidence__heading">
|
<div class="weight-evidence__heading">
|
||||||
<div><h4>称重图片与确认重量</h4><p>称重图片独立展示;重量值直接采用服务端确认结果。</p></div>
|
<div><h4>称重图片与确认重量</h4><p>称重图片独立展示;重量值直接采用服务端确认结果。</p></div>
|
||||||
@@ -1670,6 +2158,7 @@ onBeforeUnmount(() => {
|
|||||||
:customer-id="String(measurementOrder.tenantId)"
|
:customer-id="String(measurementOrder.tenantId)"
|
||||||
:inbound-id="String(measurementOrder.inboundId)"
|
:inbound-id="String(measurementOrder.inboundId)"
|
||||||
:inbound-code="measurementOrder.inboundCode"
|
:inbound-code="measurementOrder.inboundCode"
|
||||||
|
:open-warehouse-suggestion="openWarehouseSuggestionOnMeasurementLoad"
|
||||||
@updated="handleMeasurementUpdated"
|
@updated="handleMeasurementUpdated"
|
||||||
@submitted="handleMeasurementSubmitted"
|
@submitted="handleMeasurementSubmitted"
|
||||||
@closed="clearMeasurementOrder"
|
@closed="clearMeasurementOrder"
|
||||||
@@ -1765,6 +2254,15 @@ onBeforeUnmount(() => {
|
|||||||
.panel-title h2 { margin: 0; color: #344054; font-size: 16px; }
|
.panel-title h2 { margin: 0; color: #344054; font-size: 16px; }
|
||||||
.panel-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; }
|
.panel-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; }
|
||||||
.panel-title > svg { color: var(--cc-primary); }
|
.panel-title > svg { color: var(--cc-primary); }
|
||||||
|
.measurement-title-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
|
.measurement-title-actions :deep(.el-button) { margin-left: 0; }
|
||||||
|
.fee-confirm-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
|
||||||
|
.fee-confirm-number-wrap { position: relative; width: 100%; }
|
||||||
|
.fee-confirm-number { width: 100%; }
|
||||||
|
.fee-confirm-number :deep(.el-input__wrapper) { padding-right: 42px; }
|
||||||
|
.fee-confirm-unit { position: absolute; top: 50%; right: 12px; z-index: 1; transform: translateY(-50%); color: var(--cc-muted); pointer-events: none; }
|
||||||
|
.fee-confirm-reference-title { margin: 8px 0 12px; color: var(--cc-text); font-size: 14px; }
|
||||||
|
@media (max-width: 680px) { .fee-confirm-form { grid-template-columns: 1fr; } .fee-confirm-unit { right: 12px; } }
|
||||||
.fee-section-title { margin: 14px 0 8px; color: #344054; font-size: 13px; line-height: 1.4; }
|
.fee-section-title { margin: 14px 0 8px; color: #344054; font-size: 13px; line-height: 1.4; }
|
||||||
.fee-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 8px 0 0; border: 1px solid var(--cc-border); }
|
.fee-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 8px 0 0; border: 1px solid var(--cc-border); }
|
||||||
.fee-overview > div { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 4px 12px; padding: 10px 12px; }
|
.fee-overview > div { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 4px 12px; padding: 10px 12px; }
|
||||||
@@ -1783,13 +2281,19 @@ onBeforeUnmount(() => {
|
|||||||
.cargo-image { width: 72px; height: 56px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
.cargo-image { width: 72px; height: 56px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
||||||
.measurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
.measurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||||
.measurement-section { margin-top: 18px; }
|
.measurement-section { margin-top: 18px; }
|
||||||
.measurement-section-title { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 16px; padding: 9px 14px; border: 1px solid var(--cc-border); border-radius: 7px 7px 0 0; background: #f8fafc; color: var(--cc-text); font: inherit; text-align: left; cursor: pointer; }
|
.measurement-section-title { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 12px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 7px 7px 0 0; background: #f8fafc; color: var(--cc-text); }
|
||||||
.measurement-section-title:hover { background: #f2f6fb; }
|
.measurement-section-title:hover { background: #f2f6fb; }
|
||||||
.measurement-section-title:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
|
|
||||||
.measurement-section-title[aria-expanded='false'] { border-radius: 7px; }
|
|
||||||
.measurement-section-title h3 { flex: 0 0 auto; margin: 0; color: var(--cc-text); font-size: 14px; }
|
.measurement-section-title h3 { flex: 0 0 auto; margin: 0; color: var(--cc-text); font-size: 14px; }
|
||||||
.measurement-section-summary { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 18px; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
.measurement-section-summary { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 18px; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||||
.measurement-section-title > svg { flex: 0 0 auto; color: var(--cc-muted); }
|
.measurement-section-toggle { display: flex; min-width: 0; flex: 1; align-items: center; gap: 16px; padding: 2px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.measurement-section-toggle:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
|
||||||
|
.measurement-section-toggle > svg { flex: 0 0 auto; color: var(--cc-muted); }
|
||||||
|
.measurement-section-mode { flex: 0 0 190px; }
|
||||||
|
.measurement-section-title > .measurement-section-mode { display: none; }
|
||||||
|
.measurement-rule-selector { display: flex; align-items: center; gap: 14px; margin: 8px 0 12px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 7px; background: #f8fafc; }
|
||||||
|
.measurement-rule-selector__label { color: var(--cc-text); font-size: 12px; font-weight: 650; white-space: nowrap; }
|
||||||
|
.measurement-rule-selector__control { width: 270px; }
|
||||||
|
.measurement-rule-selector__hint { color: var(--cc-muted); font-size: 11px; }
|
||||||
.measurement-total { display: flex; min-height: 42px; align-items: center; justify-content: flex-end; gap: 24px; padding: 9px 14px; border: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
.measurement-total { display: flex; min-height: 42px; align-items: center; justify-content: flex-end; gap: 24px; padding: 9px 14px; border: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
||||||
.measurement-total { border-top: 0; border-radius: 0 0 7px 7px; }
|
.measurement-total { border-top: 0; border-radius: 0 0 7px 7px; }
|
||||||
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
|
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
|
||||||
@@ -1824,5 +2328,6 @@ onBeforeUnmount(() => {
|
|||||||
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
|
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
|
||||||
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
|
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
|
||||||
@media (max-width: 720px) { .fee-overview { grid-template-columns: 1fr; } .fee-overview > div + div { border-top: 1px solid var(--cc-border); border-left: 0; } }
|
@media (max-width: 720px) { .fee-overview { grid-template-columns: 1fr; } .fee-overview > div + div { border-top: 1px solid var(--cc-border); border-left: 0; } }
|
||||||
|
@media (max-width: 720px) { .measurement-section-title { align-items: stretch; flex-direction: column; } .measurement-section-toggle { align-items: flex-start; flex-wrap: wrap; } .measurement-section-toggle > svg { margin-left: auto; } .measurement-section-mode { width: 100%; flex-basis: auto; } }
|
||||||
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
|
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user