fix: wait for customer dimension initialization
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user