Release/test 合并上生产 #2
@@ -3,32 +3,24 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getCustomerPricingRule,
|
||||
listCustomerPricingRules,
|
||||
type CustomerPricingRule,
|
||||
} from '@/modules/fba-pricing'
|
||||
getCustomerDimensionConfig,
|
||||
updateCustomerDimensionConfig,
|
||||
type CustomerDimensionConfig,
|
||||
} from '@/modules/dimension-config'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
|
||||
|
||||
vi.mock('@/modules/fba-pricing', () => ({
|
||||
createCustomerPricingRuleVersion: vi.fn(),
|
||||
disableCustomerPricingRule: vi.fn(),
|
||||
getCustomerPricingRule: vi.fn(),
|
||||
listCustomerPricingRules: vi.fn(),
|
||||
vi.mock('@/modules/dimension-config', () => ({
|
||||
getCustomerDimensionConfig: vi.fn(),
|
||||
updateCustomerDimensionConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const rule: CustomerPricingRule = {
|
||||
id: '91', tenantId: '8', ruleCode: 'FBA-SEA-KG', customerId: '8', customerName: '柏悦物流',
|
||||
businessType: 'FBA_SEA', chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167,
|
||||
roundingScale: 3, roundingMode: 'CEILING', currency: 'CNY', effectiveFrom: '2026-08-01T00:00:00',
|
||||
effectiveTo: null, ruleVersion: 2, status: 'ACTIVE', remark: '标准规则', createdAt: null, updatedAt: null,
|
||||
const rule: CustomerDimensionConfig = {
|
||||
tenantId: '8',
|
||||
edgeAdditionCm: 2,
|
||||
version: 3,
|
||||
updatedAt: '2026-08-07T10:20:00',
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
@@ -36,19 +28,18 @@ enableAutoUnmount(afterEach)
|
||||
describe('CustomerBillingRulesPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['firstleg:fba-pricing:rule:view', 'firstleg:fba-pricing:rule:edit']
|
||||
auth.permissions = ['firstleg:dimension-config:view', 'firstleg:dimension-config:edit']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(listCustomerPricingRules).mockResolvedValue([rule])
|
||||
vi.mocked(getCustomerPricingRule).mockResolvedValue(rule)
|
||||
vi.mocked(getCustomerDimensionConfig).mockResolvedValue(rule)
|
||||
vi.mocked(updateCustomerDimensionConfig).mockResolvedValue({ ...rule, version: 4 })
|
||||
})
|
||||
|
||||
it('loads only the current customer rules and exposes version actions', async () => {
|
||||
it('shows one customer dimension rule instead of the old pricing versions', async () => {
|
||||
mount(CustomerBillingRulesPanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '8', customerName: '柏悦物流', customerCode: 'TEN8' },
|
||||
@@ -56,15 +47,17 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(listCustomerPricingRules).toHaveBeenCalledWith({ tenantId: '8' })
|
||||
expect(document.body.textContent).toContain('FBA-SEA-KG')
|
||||
expect(document.body.textContent).toContain('FBA 海运')
|
||||
expect(document.body.textContent).toContain('新建规则')
|
||||
expect(document.body.textContent).toContain('新版本')
|
||||
expect(document.body.textContent).toContain('停用')
|
||||
expect(getCustomerDimensionConfig).toHaveBeenCalledWith('8')
|
||||
expect(document.body.textContent).toContain('尺寸计算规则')
|
||||
expect(document.body.textContent).toContain('当前每侧加宽')
|
||||
expect(document.body.textContent).toContain('+2cm')
|
||||
expect(document.body.textContent).toContain('34 × 24 × 24 cm')
|
||||
expect(document.body.textContent).toContain('0.020 CBM')
|
||||
expect(document.body.textContent).not.toContain('规则版本')
|
||||
expect(document.body.textContent).not.toContain('小箱')
|
||||
})
|
||||
|
||||
it('loads rule details with the customer tenant scope', async () => {
|
||||
it('edits the single value and submits the optimistic lock version', async () => {
|
||||
mount(CustomerBillingRulesPanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '8', customerName: '柏悦物流' },
|
||||
@@ -72,13 +65,43 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const detailButtons = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.filter((button) => button.textContent?.trim() === '详情')
|
||||
const detailButton = detailButtons.at(-1)
|
||||
detailButton?.click()
|
||||
const editButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '编辑规则')
|
||||
editButton?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(getCustomerPricingRule).toHaveBeenCalledWith('8', '91')
|
||||
expect(document.body.textContent).toContain('标准规则')
|
||||
expect(document.body.textContent).toContain('编辑尺寸计算规则')
|
||||
expect(document.body.textContent).toContain('实际内径 30 × 20 × 20 cm')
|
||||
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '保存规则')
|
||||
saveButton?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', {
|
||||
edgeAdditionCm: 2,
|
||||
version: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the editor open when the versioned save fails', async () => {
|
||||
vi.mocked(updateCustomerDimensionConfig).mockRejectedValueOnce(new Error('规则已被其他人更新'))
|
||||
mount(CustomerBillingRulesPanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '8', customerName: '柏悦物流' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const editButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '编辑规则')
|
||||
editButton?.click()
|
||||
await flushPromises()
|
||||
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '保存规则')
|
||||
saveButton?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('编辑尺寸计算规则')
|
||||
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', expect.objectContaining({ version: 3 }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { CircleSlash2, Eye, Plus, RefreshCw, Scale } from '@lucide/vue'
|
||||
import { Calculator, Pencil, RefreshCw, Ruler } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import {
|
||||
createCustomerPricingRuleVersion,
|
||||
disableCustomerPricingRule,
|
||||
getCustomerPricingRule,
|
||||
listCustomerPricingRules,
|
||||
type CustomerPricingRule,
|
||||
type CustomerRuleFormModel,
|
||||
} from '@/modules/fba-pricing'
|
||||
getCustomerDimensionConfig,
|
||||
updateCustomerDimensionConfig,
|
||||
type CustomerDimensionConfig,
|
||||
type CustomerDimensionConfigFormModel,
|
||||
} from '@/modules/dimension-config'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId: string
|
||||
@@ -24,111 +21,76 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
|
||||
const canEdit = computed(() => canView.value && access.can('firstleg:fba-pricing:rule:edit'))
|
||||
const rules = ref<CustomerPricingRule[]>([])
|
||||
const canView = computed(() => access.can('firstleg:dimension-config:view'))
|
||||
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
|
||||
const rule = ref<CustomerDimensionConfig | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedRule = ref<CustomerPricingRule | null>(null)
|
||||
const editorVisible = ref(false)
|
||||
const editorSaving = ref(false)
|
||||
const editorDirty = ref(false)
|
||||
const editorForm = ref<FormInstance>()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const form = reactive<CustomerDimensionConfigFormModel>({ edgeAdditionCm: 2 })
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'rule', label: '规则版本', minWidth: 160, slotName: 'rule' },
|
||||
{ key: 'businessType', label: '业务类型', minWidth: 120, slotName: 'businessType' },
|
||||
{ key: 'chargeMode', label: '计费方式', width: 105, slotName: 'chargeMode' },
|
||||
{ key: 'taxMode', label: '税务模式', width: 100, slotName: 'taxMode' },
|
||||
{ key: 'conversionFactor', prop: 'conversionFactor', label: '换算系数', width: 105 },
|
||||
{ key: 'rounding', label: '舍入规则', minWidth: 165, slotName: 'rounding' },
|
||||
{ key: 'currency', prop: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'period', label: '有效期', minWidth: 225, slotName: 'period' },
|
||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
||||
]
|
||||
|
||||
const pagedRules = computed(() => rules.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
const emptyForm = (): CustomerRuleFormModel => ({
|
||||
ruleCode: '',
|
||||
customerId: props.customerId,
|
||||
customerName: props.customerName,
|
||||
businessType: 'FBA_SEA',
|
||||
chargeMode: 'KG',
|
||||
taxMode: 'TAX_INCLUDED',
|
||||
conversionFactor: 167,
|
||||
roundingScale: 3,
|
||||
roundingMode: 'CEILING',
|
||||
currency: 'CNY',
|
||||
effectiveFrom: '',
|
||||
effectiveTo: null,
|
||||
remark: null,
|
||||
})
|
||||
const form = reactive<CustomerRuleFormModel>(emptyForm())
|
||||
|
||||
const formRules: FormRules<CustomerRuleFormModel> = {
|
||||
ruleCode: [{ required: true, message: '请输入规则编码', trigger: 'blur' }],
|
||||
businessType: [{ required: true, message: '请输入业务类型', trigger: 'blur' }],
|
||||
chargeMode: [{ required: true, message: '请选择计费方式', trigger: 'change' }],
|
||||
taxMode: [{ required: true, message: '请选择税务模式', trigger: 'change' }],
|
||||
conversionFactor: [{ required: true, message: '请输入大于 0 的换算系数', trigger: 'blur', type: 'number', min: 0.000001 }],
|
||||
roundingScale: [{ required: true, message: '请选择保留位数', trigger: 'change', type: 'number', min: 0, max: 4 }],
|
||||
roundingMode: [{ required: true, message: '请选择舍入方式', trigger: 'change' }],
|
||||
currency: [{ required: true, message: '请选择币种', trigger: 'change' }],
|
||||
effectiveFrom: [{ required: true, message: '请选择生效时间', trigger: 'change' }],
|
||||
const formRules: FormRules<CustomerDimensionConfigFormModel> = {
|
||||
edgeAdditionCm: [{
|
||||
required: true,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
message: '请输入 0 至 100 之间的每侧加宽值',
|
||||
trigger: ['blur', 'change'],
|
||||
}],
|
||||
}
|
||||
|
||||
const preview = computed(() => {
|
||||
const addition = Number.isFinite(form.edgeAdditionCm) ? form.edgeAdditionCm : 0
|
||||
const length = 30 + addition * 2
|
||||
const width = 20 + addition * 2
|
||||
const height = 20 + addition * 2
|
||||
return {
|
||||
length,
|
||||
width,
|
||||
height,
|
||||
volume: (length * width * height / 1_000_000).toFixed(3),
|
||||
}
|
||||
})
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: `customer-pricing-rule-${props.customerId}`,
|
||||
label: computed(() => `${props.customerName}的计费规则版本`),
|
||||
key: `customer-dimension-rule-${props.customerId}`,
|
||||
label: computed(() => `${props.customerName}的尺寸计算规则`),
|
||||
isOpen: editorVisible,
|
||||
model: form,
|
||||
requiredPermission: computed(() => 'firstleg:fba-pricing:rule:edit'),
|
||||
requiredPermission: computed(() => 'firstleg:dimension-config:edit'),
|
||||
})
|
||||
|
||||
watch(form, () => {
|
||||
if (editorVisible.value) editorDirty.value = true
|
||||
}, { deep: true })
|
||||
|
||||
async function loadRules() {
|
||||
watch(() => props.customerId, () => {
|
||||
editorVisible.value = false
|
||||
void loadRule()
|
||||
})
|
||||
|
||||
async function loadRule() {
|
||||
if (!canView.value) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
rules.value = await listCustomerPricingRules({ tenantId: props.customerId })
|
||||
page.value = 1
|
||||
rule.value = await getCustomerDimensionConfig(props.customerId)
|
||||
} catch (error) {
|
||||
rules.value = []
|
||||
loadError.value = getApiErrorDetails(error, '客户计费规则加载失败,请稍后重试')
|
||||
rule.value = null
|
||||
loadError.value = getApiErrorDetails(error, '尺寸计算规则加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(source?: CustomerPricingRule) {
|
||||
Object.assign(form, emptyForm(), source ? {
|
||||
ruleCode: source.ruleCode,
|
||||
businessType: source.businessType,
|
||||
chargeMode: source.chargeMode,
|
||||
taxMode: source.taxMode,
|
||||
conversionFactor: source.conversionFactor,
|
||||
roundingScale: source.roundingScale,
|
||||
roundingMode: source.roundingMode,
|
||||
currency: source.currency,
|
||||
remark: source.remark,
|
||||
} : {})
|
||||
form.customerId = props.customerId
|
||||
form.customerName = props.customerName
|
||||
}
|
||||
|
||||
async function openEditor(source?: CustomerPricingRule) {
|
||||
resetForm(source)
|
||||
async function openEditor() {
|
||||
if (!rule.value) return
|
||||
form.edgeAdditionCm = rule.value.edgeAdditionCm
|
||||
await nextTick()
|
||||
editorDirty.value = false
|
||||
editorVisible.value = true
|
||||
await nextTick()
|
||||
@@ -138,8 +100,10 @@ async function openEditor(source?: CustomerPricingRule) {
|
||||
|
||||
async function confirmAbandon(): Promise<boolean> {
|
||||
try {
|
||||
await ElMessageBox.confirm('尚有未保存的规则版本,确定放弃吗?', '放弃编辑', {
|
||||
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
||||
await ElMessageBox.confirm('每侧加宽值尚未保存,确定放弃修改吗?', '放弃编辑', {
|
||||
confirmButtonText: '放弃',
|
||||
cancelButtonText: '继续编辑',
|
||||
type: 'warning',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
@@ -153,162 +117,183 @@ async function beforeEditorClose(done: () => void) {
|
||||
}
|
||||
|
||||
async function closeEditor() {
|
||||
if (editorDirty.value && !await confirmAbandon()) {
|
||||
editorVisible.value = true
|
||||
return
|
||||
}
|
||||
if (editorSaving.value) return
|
||||
if (editorDirty.value && !await confirmAbandon()) return
|
||||
editorVisible.value = false
|
||||
}
|
||||
|
||||
async function saveVersion() {
|
||||
if (!await editorForm.value?.validate()) return
|
||||
if (form.effectiveTo && form.effectiveTo <= form.effectiveFrom) {
|
||||
ElMessage.warning('失效时间必须晚于生效时间')
|
||||
return
|
||||
}
|
||||
async function saveRule() {
|
||||
if (!rule.value || !await editorForm.value?.validate()) return
|
||||
editorSaving.value = true
|
||||
try {
|
||||
await createCustomerPricingRuleVersion(props.customerId, {
|
||||
...form,
|
||||
ruleCode: form.ruleCode.trim(),
|
||||
customerId: props.customerId,
|
||||
customerName: props.customerName,
|
||||
businessType: form.businessType.trim(),
|
||||
effectiveTo: form.effectiveTo || null,
|
||||
remark: form.remark?.trim() || null,
|
||||
rule.value = await updateCustomerDimensionConfig(props.customerId, {
|
||||
edgeAdditionCm: form.edgeAdditionCm,
|
||||
version: rule.value.version,
|
||||
})
|
||||
editorDirty.value = false
|
||||
editorVisible.value = false
|
||||
ElMessage.success('计费规则新版本已创建')
|
||||
await loadRules()
|
||||
ElMessage.success('尺寸计算规则已保存')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '计费规则版本创建失败').message)
|
||||
ElMessage.error(getApiErrorDetails(error, '尺寸计算规则保存失败,请刷新后重试').message)
|
||||
} finally {
|
||||
editorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: CustomerPricingRule) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
selectedRule.value = row
|
||||
try {
|
||||
selectedRule.value = await getCustomerPricingRule(props.customerId, row.id)
|
||||
} catch (error) {
|
||||
detailError.value = getApiErrorDetails(error, '计费规则详情加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
function formatCm(value: number) {
|
||||
return Number(value.toFixed(2)).toString()
|
||||
}
|
||||
|
||||
async function disableRule(row: CustomerPricingRule) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认停用规则“${row.ruleCode}”V${row.ruleVersion ?? '—'}吗?历史费用不会改变。`, '停用计费规则', {
|
||||
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning',
|
||||
})
|
||||
await disableCustomerPricingRule(props.customerId, row.id)
|
||||
ElMessage.success('计费规则已停用')
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(getApiErrorDetails(error, '计费规则停用失败').message)
|
||||
}
|
||||
function formatDate(value: string | null) {
|
||||
return value ? value.replace('T', ' ').slice(0, 16) : '暂无记录'
|
||||
}
|
||||
|
||||
function asRule(value: unknown) { return value as CustomerPricingRule }
|
||||
function formatDate(value: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '长期有效' }
|
||||
function modeLabel(value: string) { return value === 'KG' ? '按 KG' : value === 'CBM' ? '按 CBM' : value }
|
||||
function taxLabel(value: string) { return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value }
|
||||
function businessTypeLabel(value: string) { return value === 'FBA_SEA' ? 'FBA 海运' : value }
|
||||
|
||||
onMounted(loadRules)
|
||||
onMounted(loadRule)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="billing-rules-panel">
|
||||
<div v-if="!canView || loadError" class="panel-title billing-heading">
|
||||
<div><h2>计费规则</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的专属 FBA 计费版本</p></div>
|
||||
<div class="heading-actions">
|
||||
<el-button v-if="canView" :loading="loading" aria-label="刷新计费规则" @click="loadRules"><RefreshCw :size="16" />刷新</el-button>
|
||||
<el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor()"><Plus :size="16" />新建规则</el-button>
|
||||
<div class="dimension-rule-panel">
|
||||
<header class="rule-heading">
|
||||
<div>
|
||||
<h2>尺寸计算规则</h2>
|
||||
<p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的外箱尺寸加边配置</p>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<el-button v-if="canView" :loading="loading" aria-label="刷新尺寸计算规则" @click="loadRule">
|
||||
<RefreshCw :size="16" />刷新
|
||||
</el-button>
|
||||
<el-button v-if="canEdit && rule" type="primary" :disabled="loading" @click="openEditor">
|
||||
<Pencil :size="16" />编辑规则
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!canView" class="rule-state">
|
||||
<Ruler :size="30" />
|
||||
<strong>暂无计费规则查看权限</strong>
|
||||
<p>需要权限 firstleg:dimension-config:view。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!canView" class="billing-state">
|
||||
<Scale :size="30" /><strong>暂无计费规则查看权限</strong><p>需要权限 firstleg:fba-pricing:rule:view。</p>
|
||||
<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="loadRule">重新加载</el-button>
|
||||
</div>
|
||||
<div v-else-if="loadError" class="billing-error" role="alert">
|
||||
<div><strong>客户计费规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
|
||||
<el-button type="primary" plain :loading="loading" @click="loadRules">重新加载</el-button>
|
||||
</div>
|
||||
<DataTable
|
||||
v-else
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="pagedRules"
|
||||
:columns="columns"
|
||||
preference-key="customer-billing-rules-table"
|
||||
:loading="loading"
|
||||
:total="rules.length"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
row-key="id"
|
||||
empty-text="当前客户暂无计费规则"
|
||||
|
||||
<section v-else v-loading="loading" class="rule-content" aria-live="polite">
|
||||
<div class="formula-band">
|
||||
<div><span>外径计算</span><strong>实际内径 + 每侧加宽 × 2</strong></div>
|
||||
<div><span>体积计算</span><strong>外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</strong></div>
|
||||
</div>
|
||||
|
||||
<div v-if="rule" class="rule-summary">
|
||||
<div class="addition-value">
|
||||
<span>当前每侧加宽</span>
|
||||
<strong>+{{ formatCm(rule.edgeAdditionCm) }}<small>cm</small></strong>
|
||||
<p>长度、宽度和高度的两侧均使用该加宽值。</p>
|
||||
</div>
|
||||
<div class="example-preview">
|
||||
<div class="example-title"><Calculator :size="18" /><strong>计算示例</strong></div>
|
||||
<dl>
|
||||
<div><dt>客户填写的实际内径</dt><dd>30 × 20 × 20 cm</dd></div>
|
||||
<div><dt>计算后的外径</dt><dd>{{ formatCm(30 + rule.edgeAdditionCm * 2) }} × {{ formatCm(20 + rule.edgeAdditionCm * 2) }} × {{ formatCm(20 + rule.edgeAdditionCm * 2) }} cm</dd></div>
|
||||
<div><dt>外箱体积</dt><dd>{{ (((30 + rule.edgeAdditionCm * 2) * (20 + rule.edgeAdditionCm * 2) * (20 + rule.edgeAdditionCm * 2)) / 1_000_000).toFixed(3) }} CBM</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="rule" class="updated-at">最后更新:{{ formatDate(rule.updatedAt) }}</p>
|
||||
</section>
|
||||
|
||||
<AppDialog
|
||||
v-model="editorVisible"
|
||||
title="编辑尺寸计算规则"
|
||||
width="min(720px, calc(100vw - 28px))"
|
||||
:confirm-loading="editorSaving"
|
||||
:show-cancel="false"
|
||||
:before-close="beforeEditorClose"
|
||||
@confirm="saveRule"
|
||||
>
|
||||
<template #toolbar-start><div><h2>计费规则</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的专属 FBA 计费版本</p></div></template>
|
||||
<template #toolbar-actions><div class="heading-actions"><el-button v-if="canView" :loading="loading" aria-label="刷新计费规则" @click="loadRules"><RefreshCw :size="16" />刷新</el-button><el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor()"><Plus :size="16" />新建规则</el-button></div></template>
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" @click="openDetail(asRule(row))"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="canEdit" link type="primary" @click="openEditor(asRule(row))"><Plus :size="15" />新版本</el-button>
|
||||
<el-button v-if="canEdit && row.status === 'ACTIVE'" link type="danger" @click="disableRule(asRule(row))"><CircleSlash2 :size="15" />停用</el-button>
|
||||
<div class="editor-content">
|
||||
<div class="customer-context"><span>适用客户</span><strong>{{ customerName }}</strong><small>{{ customerCode || customerId }}</small></div>
|
||||
<p class="editor-formula">外径 = 实际内径 + 每侧加宽 × 2;体积 = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
||||
<el-form ref="editorForm" :model="form" :rules="formRules" label-position="top">
|
||||
<el-form-item label="每侧加宽(cm)" prop="edgeAdditionCm">
|
||||
<el-input-number v-model="form.edgeAdditionCm" :min="0" :max="100" :precision="2" :step="0.5" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="live-preview">
|
||||
<span>示例:实际内径 30 × 20 × 20 cm</span>
|
||||
<strong>外径 {{ formatCm(preview.length) }} × {{ formatCm(preview.width) }} × {{ formatCm(preview.height) }} cm</strong>
|
||||
<small>体积 {{ preview.volume }} CBM</small>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-actions">
|
||||
<el-button :disabled="editorSaving" @click="closeEditor">取消</el-button>
|
||||
<el-button type="primary" :loading="editorSaving" @click="saveRule">保存规则</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #rule="{ row }"><div class="primary-cell"><strong>{{ row.ruleCode }}</strong><small>V{{ row.ruleVersion ?? '—' }}</small></div></template>
|
||||
<template #businessType="{ row }">{{ businessTypeLabel(row.businessType) }}</template>
|
||||
<template #chargeMode="{ row }"><el-tag effect="plain">{{ modeLabel(row.chargeMode) }}</el-tag></template>
|
||||
<template #taxMode="{ row }">{{ taxLabel(row.taxMode) }}</template>
|
||||
<template #rounding="{ row }">保留 {{ row.roundingScale }} 位 · {{ row.roundingMode }}</template>
|
||||
<template #period="{ row }"><span>{{ formatDate(row.effectiveFrom) }}</span><small class="subline">至 {{ formatDate(row.effectiveTo) }}</small></template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></template>
|
||||
<template #empty>
|
||||
<div class="billing-state"><Scale :size="28" /><strong>当前客户暂无计费规则</strong><p v-if="canEdit">创建第一条规则后,入库计费才能匹配该客户的计费口径。</p></div>
|
||||
</template>
|
||||
<template #pagination-left>共 {{ rules.length }} 个规则版本</template>
|
||||
</DataTable>
|
||||
|
||||
<AppDrawer v-model="detailVisible" title="计费规则详情" size="min(600px, 94vw)" :loading="detailLoading" :show-footer="false">
|
||||
<div v-if="detailError" class="billing-error" role="alert"><div><strong>规则详情加载失败</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /></div></div>
|
||||
<div v-else-if="selectedRule" class="rule-detail">
|
||||
<section><h3>规则归属</h3><dl class="detail-grid"><div><dt>规则编码</dt><dd>{{ selectedRule.ruleCode }}</dd></div><div><dt>版本</dt><dd>V{{ selectedRule.ruleVersion ?? '—' }}</dd></div><div><dt>适用客户</dt><dd>{{ selectedRule.customerName }}</dd></div><div><dt>业务类型</dt><dd>{{ businessTypeLabel(selectedRule.businessType) }}</dd></div><div><dt>状态</dt><dd><el-tag :type="selectedRule.status === 'ACTIVE' ? 'success' : 'info'">{{ selectedRule.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></dd></div></dl></section>
|
||||
<section><h3>计费口径</h3><dl class="detail-grid"><div><dt>计费方式</dt><dd>{{ modeLabel(selectedRule.chargeMode) }}</dd></div><div><dt>税务模式</dt><dd>{{ taxLabel(selectedRule.taxMode) }}</dd></div><div><dt>换算系数</dt><dd>{{ selectedRule.conversionFactor }}</dd></div><div><dt>舍入规则</dt><dd>保留 {{ selectedRule.roundingScale }} 位,{{ selectedRule.roundingMode }}</dd></div><div><dt>币种</dt><dd>{{ selectedRule.currency }}</dd></div></dl></section>
|
||||
<section><h3>版本有效期</h3><dl class="detail-grid"><div><dt>生效时间</dt><dd>{{ formatDate(selectedRule.effectiveFrom) }}</dd></div><div><dt>失效时间</dt><dd>{{ formatDate(selectedRule.effectiveTo) }}</dd></div><div class="detail-wide"><dt>备注</dt><dd>{{ selectedRule.remark || '—' }}</dd></div></dl></section>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDrawer v-model="editorVisible" title="创建计费规则版本" size="min(760px, 94vw)" :confirm-loading="editorSaving" confirm-text="创建版本" :before-close="beforeEditorClose" @confirm="saveVersion" @cancel="closeEditor">
|
||||
<el-form ref="editorForm" :model="form" :rules="formRules" label-position="top" class="rule-form">
|
||||
<div class="customer-context"><span>适用客户</span><strong>{{ customerName }}</strong><small>{{ customerCode || customerId }}</small></div>
|
||||
<section class="form-section"><h3>规则归属</h3><div class="form-grid"><el-form-item label="规则编码" prop="ruleCode"><el-input v-model="form.ruleCode" maxlength="50" placeholder="例如 FBA-SEA-TAX-KG" /></el-form-item><el-form-item label="业务类型" prop="businessType"><el-input v-model="form.businessType" maxlength="32" placeholder="例如 FBA_SEA" /></el-form-item></div></section>
|
||||
<section class="form-section"><h3>计费口径</h3><div class="form-grid"><el-form-item label="计费方式" prop="chargeMode"><el-segmented v-model="form.chargeMode" :options="[{ label: '按 KG', value: 'KG' }, { label: '按 CBM', value: 'CBM' }]" /></el-form-item><el-form-item label="税务模式" prop="taxMode"><el-select v-model="form.taxMode"><el-option label="包税" value="TAX_INCLUDED" /><el-option label="不包税" value="TAX_EXCLUDED" /></el-select></el-form-item><el-form-item label="换算系数" prop="conversionFactor"><el-input-number v-model="form.conversionFactor" :min="0.000001" :precision="6" :controls="false" /></el-form-item><el-form-item label="保留小数位" prop="roundingScale"><el-select v-model="form.roundingScale"><el-option v-for="scale in [0, 1, 2, 3, 4]" :key="scale" :label="`${scale} 位`" :value="scale" /></el-select></el-form-item><el-form-item label="舍入方式" prop="roundingMode"><el-select v-model="form.roundingMode"><el-option label="向上取整 CEILING" value="CEILING" /><el-option label="向上舍入 UP" value="UP" /><el-option label="向下舍入 DOWN" value="DOWN" /><el-option label="向下取整 FLOOR" value="FLOOR" /><el-option label="四舍五入 HALF_UP" value="HALF_UP" /><el-option label="五舍六入 HALF_DOWN" value="HALF_DOWN" /><el-option label="银行家舍入 HALF_EVEN" value="HALF_EVEN" /></el-select></el-form-item><el-form-item label="币种" prop="currency"><el-select v-model="form.currency" filterable allow-create default-first-option><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item></div></section>
|
||||
<section class="form-section"><h3>生效范围</h3><div class="form-grid"><el-form-item label="生效时间" prop="effectiveFrom"><el-date-picker v-model="form.effectiveFrom" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择生效时间" /></el-form-item><el-form-item label="失效时间"><el-date-picker v-model="form.effectiveTo" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="不填表示长期有效" /></el-form-item><el-form-item label="备注" class="wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></div></section>
|
||||
</el-form>
|
||||
</AppDrawer>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.billing-rules-panel { min-width:0; }
|
||||
.billing-heading { align-items:center; }
|
||||
.heading-actions,.row-actions { display:flex; align-items:center; gap:8px; }
|
||||
.heading-actions :deep(.el-button),.row-actions :deep(.el-button) { margin-left:0; }
|
||||
.billing-state { display:flex; min-height:220px; align-items:center; justify-content:center; flex-direction:column; gap:8px; color:#8797b2; text-align:center; }
|
||||
.billing-state strong { color:#475467; font-size:13px; }.billing-state p { max-width:520px; margin:0; color:var(--cc-muted); font-size:12px; line-height:1.7; }
|
||||
.billing-error { display:flex; align-items:center; justify-content:space-between; gap:18px; padding:16px; border-radius:12px; background:#fff4f3; }.billing-error strong { color:#7a271a; }.billing-error p { margin:5px 0; color:#b42318; font-size:12px; }
|
||||
.primary-cell { display:flex; flex-direction:column; gap:3px; }.primary-cell small,.subline { display:block; margin-top:3px; color:var(--cc-muted); font-size:11px; }
|
||||
.rule-detail { display:grid; gap:24px; }.rule-detail section + section,.form-section + .form-section { padding-top:22px; border-top:1px solid var(--cc-border); }.rule-detail h3,.form-section h3 { margin:0 0 14px; color:var(--cc-text); font-size:15px; }
|
||||
.detail-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px 24px; margin:0; }.detail-grid > div { min-width:0; }.detail-grid dt { margin-bottom:5px; color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:0; color:var(--cc-text); line-height:1.55; overflow-wrap:anywhere; }.detail-wide { grid-column:1 / -1; }
|
||||
.rule-form { display:grid; gap:24px; }.customer-context { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:#f5f7ff; }.customer-context span,.customer-context small { color:var(--cc-muted); font-size:12px; }.customer-context strong { color:#25324a; }.form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:2px 20px; }.wide { grid-column:1 / -1; }.form-grid :deep(.el-select),.form-grid :deep(.el-input-number),.form-grid :deep(.el-date-editor) { width:100%; }
|
||||
@media (max-width:760px) { .billing-heading { align-items:stretch; flex-direction:column; }.heading-actions { width:100%; }.heading-actions :deep(.el-button) { min-height:44px; flex:1; }.form-grid,.detail-grid { grid-template-columns:1fr; }.wide,.detail-wide { grid-column:auto; }.billing-error { align-items:stretch; flex-direction:column; } }
|
||||
@media (max-width:480px) { .customer-context { grid-template-columns:1fr; gap:4px; }.row-actions { flex-wrap:wrap; } }
|
||||
.dimension-rule-panel { min-width: 0; }
|
||||
.rule-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.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 { display: flex; align-items: center; gap: 8px; }
|
||||
.heading-actions :deep(.el-button) { margin-left: 0; }
|
||||
.rule-content { min-height: 260px; }
|
||||
.formula-band { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(360px, 1.2fr); gap: 1px; margin: 0 -20px; background: var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.formula-band > div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 14px 20px; background: #f8fafc; }
|
||||
.formula-band span { color: var(--cc-muted); font-size: 12px; }
|
||||
.formula-band strong { color: #344054; font-size: 13px; font-weight: 600; }
|
||||
.rule-summary { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(320px, 1.3fr); gap: 32px; padding: 28px 0 22px; }
|
||||
.addition-value { display: flex; min-width: 0; flex-direction: column; justify-content: center; padding: 6px 24px; border-right: 1px solid var(--cc-border); }
|
||||
.addition-value > span { color: var(--cc-muted); font-size: 13px; }
|
||||
.addition-value > strong { margin-top: 8px; color: #6941c6; font-size: 38px; line-height: 1; }
|
||||
.addition-value small { margin-left: 5px; font-size: 15px; font-weight: 600; }
|
||||
.addition-value p { margin: 12px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.7; }
|
||||
.example-preview { min-width: 0; }
|
||||
.example-title { display: flex; align-items: center; gap: 8px; color: #344054; }
|
||||
.example-preview dl { display: grid; gap: 12px; margin: 18px 0 0; }
|
||||
.example-preview dl > div { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1.2fr); gap: 16px; }
|
||||
.example-preview dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.example-preview dd { margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
|
||||
.updated-at { margin: 0; padding-top: 16px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
||||
.rule-state { display: flex; min-height: 260px; 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; }
|
||||
.editor-content { display: grid; gap: 20px; }
|
||||
.customer-context { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 8px; background: #f5f7ff; }
|
||||
.customer-context span,.customer-context small { color: var(--cc-muted); font-size: 12px; }
|
||||
.customer-context strong { color: #25324a; }
|
||||
.editor-formula { margin: 0; padding: 13px 15px; background: #f8fafc; color: #475467; font-size: 13px; line-height: 1.7; }
|
||||
.editor-content :deep(.el-input-number) { width: min(260px, 100%); }
|
||||
.live-preview { display: grid; gap: 6px; padding: 16px; border: 1px solid #d9d6fe; border-radius: 8px; background: #f8f7ff; }
|
||||
.live-preview span,.live-preview small { color: var(--cc-muted); font-size: 12px; }
|
||||
.live-preview strong { color: #42307d; }
|
||||
.dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.dialog-actions :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 760px) {
|
||||
.rule-heading { align-items: stretch; flex-direction: column; }
|
||||
.heading-actions { width: 100%; }
|
||||
.heading-actions :deep(.el-button) { min-height: 44px; flex: 1; }
|
||||
.formula-band,.rule-summary { grid-template-columns: 1fr; }
|
||||
.formula-band { margin: 0; }
|
||||
.addition-value { padding: 4px 0 20px; border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.rule-summary { gap: 22px; }
|
||||
.rule-error { align-items: stretch; flex-direction: column; }
|
||||
.customer-context { grid-template-columns: 1fr; gap: 4px; }
|
||||
.example-preview dl > div { grid-template-columns: 1fr; gap: 4px; }
|
||||
.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const http = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http,
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
import {
|
||||
getCustomerDimensionConfig,
|
||||
updateCustomerDimensionConfig,
|
||||
} from './api'
|
||||
|
||||
const rawConfig = {
|
||||
tenantId: 9007199254740993n.toString(),
|
||||
edgeAdditionCm: 2,
|
||||
version: 3,
|
||||
updatedAt: '2026-08-07T10:20:00',
|
||||
}
|
||||
|
||||
describe('customer dimension config api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads the current customer config and keeps long ids as strings', async () => {
|
||||
http.get.mockResolvedValue({ data: rawConfig })
|
||||
|
||||
const result = await getCustomerDimensionConfig('9007199254740993')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/customer-dimension-config', {
|
||||
params: { tenantId: '9007199254740993' },
|
||||
})
|
||||
expect(result).toEqual(rawConfig)
|
||||
})
|
||||
|
||||
it('sends the current version when updating the edge addition', async () => {
|
||||
http.put.mockResolvedValue({ data: { ...rawConfig, edgeAdditionCm: 3, version: 4 } })
|
||||
|
||||
const result = await updateCustomerDimensionConfig('9007199254740993', {
|
||||
edgeAdditionCm: 3,
|
||||
version: 3,
|
||||
})
|
||||
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/customer-dimension-config',
|
||||
{ edgeAdditionCm: 3, version: 3 },
|
||||
{ params: { tenantId: '9007199254740993' } },
|
||||
)
|
||||
expect(result).toMatchObject({ tenantId: '9007199254740993', edgeAdditionCm: 3, version: 4 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
CustomerDimensionConfig,
|
||||
UpdateCustomerDimensionConfigRequest,
|
||||
} from './types'
|
||||
|
||||
const dimensionConfigPath = '/platform/v1/first-leg/customer-dimension-config'
|
||||
|
||||
type RawDimensionConfig = Omit<CustomerDimensionConfig, 'tenantId'> & {
|
||||
tenantId: string | number
|
||||
}
|
||||
|
||||
function normalizeConfig(config: RawDimensionConfig): CustomerDimensionConfig {
|
||||
return {
|
||||
...config,
|
||||
tenantId: String(config.tenantId),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomerDimensionConfig(tenantId: string): Promise<CustomerDimensionConfig> {
|
||||
const response = await http.get<never, ApiEnvelope<RawDimensionConfig>>(dimensionConfigPath, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return normalizeConfig(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function updateCustomerDimensionConfig(
|
||||
tenantId: string,
|
||||
request: UpdateCustomerDimensionConfigRequest,
|
||||
): Promise<CustomerDimensionConfig> {
|
||||
const response = await http.put<never, ApiEnvelope<RawDimensionConfig>>(dimensionConfigPath, request, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return normalizeConfig(unwrapApiResponse(response))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
getCustomerDimensionConfig,
|
||||
updateCustomerDimensionConfig,
|
||||
} from './api'
|
||||
|
||||
export type {
|
||||
CustomerDimensionConfig,
|
||||
CustomerDimensionConfigFormModel,
|
||||
UpdateCustomerDimensionConfigRequest,
|
||||
} from './types'
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface CustomerDimensionConfig {
|
||||
tenantId: string
|
||||
edgeAdditionCm: number
|
||||
version: number
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface UpdateCustomerDimensionConfigRequest {
|
||||
edgeAdditionCm: number
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface CustomerDimensionConfigFormModel {
|
||||
edgeAdditionCm: number
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const http = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http,
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
import {
|
||||
createCustomerPricingRuleVersion,
|
||||
disableCustomerPricingRule,
|
||||
getCustomerPricingRule,
|
||||
listCustomerPricingRules,
|
||||
} from './api'
|
||||
|
||||
const rawRule = {
|
||||
id: 9007199254740993n.toString(),
|
||||
tenantId: 10001,
|
||||
ruleCode: 'FBA-TAX-KG',
|
||||
customerId: 20002,
|
||||
customerName: '测试企业',
|
||||
businessType: 'FBA',
|
||||
chargeMode: 'KG',
|
||||
taxMode: 'TAX_INCLUDED',
|
||||
conversionFactor: 167,
|
||||
roundingScale: 3,
|
||||
roundingMode: 'CEILING',
|
||||
currency: 'CNY',
|
||||
effectiveFrom: '2026-08-04T00:00:00',
|
||||
effectiveTo: null,
|
||||
ruleVersion: 1,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
}
|
||||
|
||||
describe('customer pricing rules api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('omits an empty customer scope and rule code for the all-customer list', async () => {
|
||||
http.get.mockResolvedValue({ data: [rawRule] })
|
||||
|
||||
await listCustomerPricingRules({ tenantId: ' ', ruleCode: ' ' })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules', { params: {} })
|
||||
})
|
||||
|
||||
it('keeps every long id as a string and sends the real list query', async () => {
|
||||
http.get.mockResolvedValue({ data: [rawRule] })
|
||||
|
||||
const result = await listCustomerPricingRules({ tenantId: '10001', ruleCode: ' FBA ' })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules', {
|
||||
params: { tenantId: '10001', ruleCode: 'FBA' },
|
||||
})
|
||||
expect(result[0]).toMatchObject({ id: '9007199254740993', tenantId: '10001', customerId: '20002' })
|
||||
})
|
||||
|
||||
it('uses detail, version creation and disable endpoints from the controller', async () => {
|
||||
http.get.mockResolvedValue({ data: rawRule })
|
||||
http.post.mockResolvedValue({ data: rawRule })
|
||||
http.delete.mockResolvedValue({ data: null })
|
||||
|
||||
await getCustomerPricingRule('10001', '9')
|
||||
await createCustomerPricingRuleVersion('10001', {
|
||||
ruleCode: 'FBA-TAX-KG', customerId: '20002', customerName: '测试企业', businessType: 'FBA',
|
||||
chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167, roundingScale: 3,
|
||||
roundingMode: 'CEILING', currency: 'CNY', effectiveFrom: '2026-08-04T00:00:00',
|
||||
})
|
||||
await disableCustomerPricingRule('10001', '9')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/9', { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/versions', expect.any(Object), { params: { tenantId: '10001' } })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/9', { params: { tenantId: '10001' } })
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
CreateCustomerRuleVersionRequest,
|
||||
CustomerPricingRule,
|
||||
CustomerRuleQuery,
|
||||
} from './types'
|
||||
|
||||
type RawRule = Omit<CustomerPricingRule, 'id' | 'tenantId' | 'customerId'> & {
|
||||
id: string | number
|
||||
tenantId: string | number
|
||||
customerId: string | number
|
||||
}
|
||||
|
||||
function normalizeRule(rule: RawRule): CustomerPricingRule {
|
||||
return {
|
||||
...rule,
|
||||
id: String(rule.id),
|
||||
tenantId: String(rule.tenantId),
|
||||
customerId: String(rule.customerId),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCustomerPricingRules(query: CustomerRuleQuery): Promise<CustomerPricingRule[]> {
|
||||
const tenantId = query.tenantId?.trim()
|
||||
const ruleCode = query.ruleCode?.trim()
|
||||
const response = await http.get<never, ApiEnvelope<RawRule[]>>('/platform/v1/fba-pricing/customer-rules', {
|
||||
params: {
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
...(ruleCode ? { ruleCode } : {}),
|
||||
},
|
||||
})
|
||||
return unwrapApiResponse(response).map(normalizeRule)
|
||||
}
|
||||
|
||||
export async function getCustomerPricingRule(tenantId: string, id: string): Promise<CustomerPricingRule> {
|
||||
const response = await http.get<never, ApiEnvelope<RawRule>>(`/platform/v1/fba-pricing/customer-rules/${id}`, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return normalizeRule(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createCustomerPricingRuleVersion(
|
||||
tenantId: string,
|
||||
request: CreateCustomerRuleVersionRequest,
|
||||
): Promise<CustomerPricingRule> {
|
||||
const response = await http.post<never, ApiEnvelope<RawRule>>(
|
||||
'/platform/v1/fba-pricing/customer-rules/versions',
|
||||
request,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return normalizeRule(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function disableCustomerPricingRule(tenantId: string, id: string): Promise<void> {
|
||||
const response = await http.delete<never, ApiEnvelope<unknown>>(`/platform/v1/fba-pricing/customer-rules/${id}`, {
|
||||
params: { tenantId },
|
||||
})
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
export type CustomerRuleChargeMode = 'KG' | 'CBM'
|
||||
export type CustomerRuleStatus = 'ACTIVE' | 'INACTIVE' | string
|
||||
|
||||
export interface CustomerPricingRule {
|
||||
id: string
|
||||
tenantId: string
|
||||
ruleCode: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
businessType: string
|
||||
chargeMode: CustomerRuleChargeMode
|
||||
taxMode: string
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
currency: string
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
ruleVersion: number | null
|
||||
status: CustomerRuleStatus
|
||||
remark: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CustomerRuleQuery {
|
||||
tenantId?: string
|
||||
ruleCode?: string
|
||||
}
|
||||
|
||||
export interface CreateCustomerRuleVersionRequest {
|
||||
ruleCode: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
businessType: string
|
||||
chargeMode: CustomerRuleChargeMode
|
||||
taxMode: string
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
currency: string
|
||||
effectiveFrom: string
|
||||
effectiveTo?: string | null
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface CustomerRuleFormModel extends CreateCustomerRuleVersionRequest {
|
||||
customerId: string
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export {
|
||||
createCustomerPricingRuleVersion,
|
||||
disableCustomerPricingRule,
|
||||
getCustomerPricingRule,
|
||||
listCustomerPricingRules,
|
||||
} from './customer-rules/api'
|
||||
|
||||
export type {
|
||||
CreateCustomerRuleVersionRequest,
|
||||
CustomerPricingRule,
|
||||
CustomerRuleChargeMode,
|
||||
CustomerRuleFormModel,
|
||||
CustomerRuleStatus,
|
||||
} from './customer-rules/types'
|
||||
@@ -52,16 +52,18 @@ const workspace: WeightWorkspaceResource = {
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountPanel() {
|
||||
const allWeightPermissions = [
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'platform:firstleg:inbound:weight:edit',
|
||||
'platform:firstleg:inbound:weight:confirm',
|
||||
]
|
||||
|
||||
function mountPanel(permissions = allWeightPermissions) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = [
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'platform:firstleg:inbound:weight:edit',
|
||||
'platform:firstleg:inbound:weight:confirm',
|
||||
]
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(WeightOcrPanel, {
|
||||
attachTo: document.body,
|
||||
@@ -112,4 +114,226 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
})
|
||||
expect(correctWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends an LB adjustment atomically after switching units', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('10.125')
|
||||
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 0,
|
||||
adjustedWeightValue: 10.125,
|
||||
adjustedWeightUnit: 'LB',
|
||||
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 10.125 LB',
|
||||
})
|
||||
expect(correctWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an LB value that would round to zero kilograms', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('0.001')
|
||||
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
|
||||
|
||||
expect(wrapper.get('.weight-row__editor .el-button--success').attributes('disabled')).toBeDefined()
|
||||
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts the smallest LB value that remains representable in kilograms', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('0.002')
|
||||
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 0,
|
||||
adjustedWeightValue: 0.002,
|
||||
adjustedWeightUnit: 'LB',
|
||||
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 0.002 LB',
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps a value outside the safe three-decimal JavaScript range', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
|
||||
|
||||
await input.setValue('9000000000001')
|
||||
expect((input.element as HTMLInputElement).value).toBe('9000000000000.000')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 0,
|
||||
adjustedWeightValue: 9_000_000_000_000,
|
||||
adjustedWeightUnit: 'KG',
|
||||
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 9000000000000.000 KG',
|
||||
})
|
||||
})
|
||||
|
||||
it('rounds an input with more than three decimals before confirmation', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
|
||||
|
||||
await input.setValue('1.2345')
|
||||
expect((input.element as HTMLInputElement).value).toBe('1.235')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 0,
|
||||
adjustedWeightValue: 1.235,
|
||||
adjustedWeightUnit: 'KG',
|
||||
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 1.235 KG',
|
||||
})
|
||||
})
|
||||
|
||||
it('confirms an existing corrected value without creating another correction', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
weightResults: [{
|
||||
...pendingWeight,
|
||||
adjustedWeightValue: 286.125,
|
||||
adjustedWeightUnit: 'KG',
|
||||
adjustmentReason: '首次修正',
|
||||
version: 1,
|
||||
}],
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 1 })
|
||||
expect(correctWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows a confirm-only operator to accept an unchanged OCR value', async () => {
|
||||
const wrapper = mountPanel([
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'platform:firstleg:inbound:weight:confirm',
|
||||
])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
|
||||
})
|
||||
|
||||
it('does not render confirmation for an edit-only operator', async () => {
|
||||
const wrapper = mountPanel([
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'platform:firstleg:inbound:weight:edit',
|
||||
])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.weight-row__editor .el-button--success').exists()).toBe(false)
|
||||
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supports manual entry when OCR did not return a value', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
weightResults: [{
|
||||
...pendingWeight,
|
||||
ocrRawText: null,
|
||||
ocrWeightValue: null,
|
||||
ocrWeightUnit: null,
|
||||
recognitionStatus: 'NEEDS_MANUAL',
|
||||
}],
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.weight-row__editor .el-button--success').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('1.500')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 0,
|
||||
adjustedWeightValue: 1.5,
|
||||
adjustedWeightUnit: 'KG',
|
||||
adjustmentReason: 'OCR 原值 —,人工确认 1.500 KG',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a supported OCR unit alias with surrounding whitespace', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
weightResults: [{ ...pendingWeight, ocrWeightUnit: ' lbs ' }],
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
|
||||
})
|
||||
|
||||
it('sends an explicit correction when OCR returns an unsupported unit', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
weightResults: [{ ...pendingWeight, ocrWeightUnit: 'G' }],
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 0,
|
||||
adjustedWeightValue: 285.5,
|
||||
adjustedWeightUnit: 'KG',
|
||||
adjustmentReason: 'OCR 原值 285.500 G,人工确认 285.500 KG',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an explicit correction when OCR returns a value without a unit', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
weightResults: [{ ...pendingWeight, ocrWeightUnit: null }],
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 0,
|
||||
adjustedWeightValue: 285.5,
|
||||
adjustedWeightUnit: 'KG',
|
||||
adjustmentReason: 'OCR 原值 285.500(单位缺失),人工确认 285.500 KG',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the edited draft available when confirmation fails', async () => {
|
||||
vi.mocked(confirmWeightResult).mockRejectedValueOnce(new Error('version conflict'))
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
|
||||
|
||||
await input.setValue('286.125')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('286.125')
|
||||
expect(wrapper.text()).toContain('version conflict')
|
||||
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,6 +45,8 @@ const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB
|
||||
let loadSequence = 0
|
||||
|
||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
|
||||
const maximumSafeWeight = 9_000_000_000_000
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit'))
|
||||
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
|
||||
@@ -83,7 +85,11 @@ function isActionLoading(action: string, id: string) {
|
||||
}
|
||||
|
||||
function normalizeUnit(value: string | null) {
|
||||
return value?.toUpperCase().startsWith('LB') ? 'LB' as const : 'KG' as const
|
||||
return value?.trim().toUpperCase().startsWith('LB') ? 'LB' as const : 'KG' as const
|
||||
}
|
||||
|
||||
function isSupportedUnit(value: string | null) {
|
||||
return ['KG', 'KGS', 'LB', 'LBS'].includes(value?.trim().toUpperCase() || '')
|
||||
}
|
||||
|
||||
function syncWeightDrafts(results: WeightResultResource[]) {
|
||||
@@ -95,21 +101,30 @@ function syncWeightDrafts(results: WeightResultResource[]) {
|
||||
|
||||
function isWeightDraftValid(row: WeightResultResource) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
return Boolean(draft && Number.isFinite(Number(draft.value)) && Number(draft.value) > 0)
|
||||
const value = Number(draft?.value)
|
||||
return Boolean(draft
|
||||
&& Number.isFinite(value)
|
||||
&& value >= minimumWeight[draft.unit]
|
||||
&& value <= maximumSafeWeight)
|
||||
}
|
||||
|
||||
function hasWeightDraftChanged(row: WeightResultResource) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
if (!draft) return false
|
||||
const savedValue = row.adjustedWeightValue ?? row.ocrWeightValue
|
||||
const savedUnit = normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit)
|
||||
const rawSavedUnit = row.adjustedWeightUnit ?? row.ocrWeightUnit
|
||||
const savedUnit = normalizeUnit(rawSavedUnit)
|
||||
if (!isSupportedUnit(rawSavedUnit)) return true
|
||||
return Number(draft.value) !== Number(savedValue) || draft.unit !== savedUnit
|
||||
}
|
||||
|
||||
function buildAdjustmentReason(row: WeightResultResource) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
const ocrUnit = normalizeUnit(row.ocrWeightUnit)
|
||||
return `OCR 原值 ${formatWeight(row.ocrWeightValue, ocrUnit)},人工确认 ${formatWeight(Number(draft.value), draft.unit)}`
|
||||
const ocrUnit = row.ocrWeightUnit?.trim().toUpperCase()
|
||||
const ocrValue = row.ocrWeightValue == null
|
||||
? '—'
|
||||
: `${Number(row.ocrWeightValue).toFixed(3)}${ocrUnit ? ` ${ocrUnit}` : '(单位缺失)'}`
|
||||
return `OCR 原值 ${ocrValue},人工确认 ${formatWeight(Number(draft.value), draft.unit)}`
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
@@ -390,8 +405,8 @@ defineExpose({ reload: loadWorkspace })
|
||||
<el-input-number
|
||||
v-model="weightDrafts[row.id].value"
|
||||
:aria-label="`称重图片序号 ${index + 1} 的确认重量`"
|
||||
:min="0.001"
|
||||
:max="999999999999999.999"
|
||||
:min="minimumWeight[weightDrafts[row.id].unit]"
|
||||
:max="maximumSafeWeight"
|
||||
:precision="3"
|
||||
:controls="false"
|
||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
|
||||
Reference in New Issue
Block a user