This commit is contained in:
2026-08-07 19:23:43 +08:00
parent 38a0ffd3e3
commit 28d0b81c5a
12 changed files with 627 additions and 470 deletions
@@ -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>
+54
View File
@@ -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 })
})
})
+35
View File
@@ -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))
}
+10
View File
@@ -0,0 +1,10 @@
export {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
} from './api'
export type {
CustomerDimensionConfig,
CustomerDimensionConfigFormModel,
UpdateCustomerDimensionConfigRequest,
} from './types'
+15
View File
@@ -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
}
-14
View File
@@ -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"