fix: wait for customer dimension initialization

This commit is contained in:
2026-09-11 02:16:51 +08:00
parent 2817ca1d3f
commit b7b9835bf9
4 changed files with 104 additions and 3 deletions
@@ -13,6 +13,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import AppDialog from '@/shared/components/AppDialog.vue'
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
import { getPlatformTenantDimensionInitializationStatus } from '@/modules/iam'
vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn(), updateCustomerDimensionDefaultCalculationMode: vi.fn() }))
vi.mock('@/modules/fba-pricing', () => ({
@@ -21,6 +22,7 @@ vi.mock('@/modules/fba-pricing', () => ({
retireCustomerChargeRule: vi.fn(),
setCustomerChargeRuleDefault: vi.fn(),
}))
vi.mock('@/modules/iam', () => ({ getPlatformTenantDimensionInitializationStatus: vi.fn() }))
const boxes = [
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
@@ -64,6 +66,10 @@ describe('CustomerBillingRulesPanel', () => {
vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue(config)
vi.mocked(listCustomerChargeRules).mockResolvedValue([chargeRule])
vi.mocked(createCustomerChargeRule).mockResolvedValue(chargeRule)
vi.mocked(getPlatformTenantDimensionInitializationStatus).mockResolvedValue({
tenantId: '8', status: 'PENDING', attemptCount: 0, nextRetryAt: null,
lastErrorCode: null, lastErrorMessage: null, updatedAt: null,
})
})
it('renders the four fixed carton dimension rows and calculated values', async () => {
@@ -111,6 +117,24 @@ describe('CustomerBillingRulesPanel', () => {
expect(listCustomerChargeRules).not.toHaveBeenCalled()
})
it('loads billing rules automatically when asynchronous initialization becomes ready', async () => {
vi.mocked(getPlatformTenantDimensionInitializationStatus).mockResolvedValueOnce({
tenantId: '8', status: 'READY', attemptCount: 1, nextRetryAt: null,
lastErrorCode: null, lastErrorMessage: null, updatedAt: '2026-09-10T20:04:06',
})
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: { customerId: '8', customerName: '柏悦物流', dimensionInitializationStatus: 'PENDING' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(getPlatformTenantDimensionInitializationStatus).toHaveBeenCalledWith('8')
expect(getCustomerDimensionConfig).toHaveBeenCalledWith('8')
expect(listCustomerChargeRules).toHaveBeenCalledWith('8')
expect(document.body.textContent).not.toContain('箱规尺寸规则加载失败')
})
it('submits all four rows in one update request', async () => {
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Pencil, Ruler, Save } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -18,6 +18,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import { createCustomerChargeRule, listCustomerChargeRules, setCustomerChargeRuleDefault, type CustomerChargeRule, type CreateCustomerChargeRuleRequest } from '@/modules/fba-pricing'
import { getPlatformTenantDimensionInitializationStatus } from '@/modules/iam'
const props = defineProps<{
customerId: string
@@ -30,7 +31,8 @@ const canView = computed(() => access.can('firstleg:dimension-config:view'))
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
const canChargeView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
const canChargeEdit = computed(() => canChargeView.value && access.can('firstleg:fba-pricing:rule:edit'))
const initializationStatus = computed(() => props.dimensionInitializationStatus?.trim().toUpperCase() || 'READY')
const observedInitializationStatus = ref(props.dimensionInitializationStatus?.trim().toUpperCase() || 'READY')
const initializationStatus = computed(() => observedInitializationStatus.value)
const initializationPending = computed(() => ['PENDING', 'SENDING'].includes(initializationStatus.value))
const initializationFailed = computed(() => initializationStatus.value === 'FAILED')
const initializationReady = computed(() => !initializationPending.value && !initializationFailed.value)
@@ -43,6 +45,8 @@ const editorDirty = ref(false)
const defaultCalculationMode = ref<'ACTUAL' | 'DIMENSION' | 'WEIGHT'>('ACTUAL')
const defaultModeSaving = ref(false)
const editorForm = ref<FormInstance>()
let initializationPollTimer: ReturnType<typeof setTimeout> | null = null
let initializationPollGeneration = 0
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
const chargeRules = ref<CustomerChargeRule[]>([])
const visibleChargeRules = computed(() => chargeRules.value.filter((rule) => rule.status === 'ACTIVE'))
@@ -99,14 +103,24 @@ watch(form, () => {
}, { deep: true })
watch(() => props.customerId, () => {
stopInitializationPolling()
observedInitializationStatus.value = props.dimensionInitializationStatus?.trim().toUpperCase() || 'READY'
editorVisible.value = false
defaultCalculationMode.value = 'ACTUAL'
if (initializationReady.value) {
void loadConfig()
void loadChargeRules()
} else if (initializationPending.value) {
startInitializationPolling()
}
})
watch(() => props.dimensionInitializationStatus, (status) => {
observedInitializationStatus.value = status?.trim().toUpperCase() || 'READY'
if (initializationPending.value) startInitializationPolling()
else stopInitializationPolling()
})
watch(initializationReady, (ready, previous) => {
if (ready && !previous) {
void loadConfig()
@@ -114,6 +128,32 @@ watch(initializationReady, (ready, previous) => {
}
})
function stopInitializationPolling() {
initializationPollGeneration += 1
if (initializationPollTimer) clearTimeout(initializationPollTimer)
initializationPollTimer = null
}
function startInitializationPolling() {
if (initializationPollTimer) return
const generation = ++initializationPollGeneration
const tenantId = props.customerId
const poll = async () => {
initializationPollTimer = null
try {
const result = await getPlatformTenantDimensionInitializationStatus(tenantId)
if (generation !== initializationPollGeneration || tenantId !== props.customerId) return
observedInitializationStatus.value = result.status?.trim().toUpperCase() || 'PENDING'
} catch {
// Initialization is retried by IAM. A transient diagnostic failure must not trigger the dimension API early.
}
if (generation === initializationPollGeneration && tenantId === props.customerId && initializationPending.value) {
initializationPollTimer = setTimeout(poll, 2_000)
}
}
void poll()
}
function sortRules(rules: CustomerDimensionRule[]) {
return [...rules].sort((a, b) => BOX_ORDER.indexOf(a.boxType) - BOX_ORDER.indexOf(b.boxType))
.map((rule) => ({ ...rule, boxTypeName: BOX_NAMES[rule.boxType] || rule.boxTypeName }))
@@ -307,10 +347,16 @@ async function loadChargeRules() {
}
onMounted(() => {
if (initializationPending.value) {
startInitializationPolling()
return
}
if (!initializationReady.value) return
void loadConfig()
void loadChargeRules()
})
onBeforeUnmount(stopInitializationPolling)
</script>
<template>
@@ -318,7 +364,7 @@ onMounted(() => {
<div v-if="initializationPending" class="rule-state initialization-state" role="status">
<Ruler :size="30" />
<strong>客户计费规则正在初始化</strong>
<p>默认箱规和权重系数正在后台准备,请稍后重新加载客户详情。</p>
<p>默认箱规和权重系数正在后台准备,完成后本页会自动加载。</p>
</div>
<div v-else-if="initializationFailed" class="rule-error initialization-state" role="alert">
<div><strong>客户计费规则初始化失败</strong><p>请在客户管理中重试初始化,成功后再查看计费规则。</p></div>
+15
View File
@@ -17,6 +17,7 @@ import {
deletePlatformPermission,
deletePlatformRole,
downloadTenantQualification,
getPlatformTenantDimensionInitializationStatus,
listPlatformTenants,
listPlatformTenantOrderCounts,
listTenantQualifications,
@@ -400,6 +401,20 @@ describe('platform IAM roles', () => {
expect(form.get('replaceLegalPersonId')).toBe('true')
})
it('loads the customer dimension initialization status before querying dimension rules', async () => {
const status = {
tenantId: '2098016473058967554', status: 'PENDING', attemptCount: 3,
nextRetryAt: '2026-09-10T20:04:00', lastErrorCode: 'S0503',
lastErrorMessage: 'Firstleg 服务暂时不可用', updatedAt: '2026-09-10T19:54:00',
}
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: status })
await expect(getPlatformTenantDimensionInitializationStatus(status.tenantId)).resolves.toEqual(status)
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/iam/tenants/2098016473058967554/dimension-initialization',
)
})
it('omits the optional legal-person ID parts when only the business license is uploaded', async () => {
const result = {
businessLicenseQualificationId: '91', legalPersonIdQualificationId: null,
+16
View File
@@ -148,6 +148,16 @@ export interface PlatformTenantCreationResponse {
temporaryPassword: string
}
export interface CustomerDimensionInitializationStatus {
tenantId: number | string
status: 'PENDING' | 'SENDING' | 'READY' | 'FAILED' | string
attemptCount: number
nextRetryAt: string | null
lastErrorCode: string | null
lastErrorMessage: string | null
updatedAt: string | null
}
export interface ResetPlatformTenantOwnerPasswordRequest {
newPassword: string
forceChangePassword: boolean
@@ -364,6 +374,12 @@ export function getPlatformTenant(tenantId: number | string) {
return getData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}`)
}
export function getPlatformTenantDimensionInitializationStatus(tenantId: number | string) {
return getData<CustomerDimensionInitializationStatus>(
`/platform/v1/iam/tenants/${tenantId}/dimension-initialization`,
)
}
export async function checkPlatformTenantContactPhone(contactPhone: string, excludeTenantId?: number | string) {
const response = await http.get<ApiEnvelope<boolean>, ApiEnvelope<boolean>>(
'/platform/v1/iam/tenants/contact-phone-availability',