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 { useAuthStore } from '@/core/auth/auth'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.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/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn(), updateCustomerDimensionDefaultCalculationMode: vi.fn() }))
|
||||||
vi.mock('@/modules/fba-pricing', () => ({
|
vi.mock('@/modules/fba-pricing', () => ({
|
||||||
@@ -21,6 +22,7 @@ vi.mock('@/modules/fba-pricing', () => ({
|
|||||||
retireCustomerChargeRule: vi.fn(),
|
retireCustomerChargeRule: vi.fn(),
|
||||||
setCustomerChargeRuleDefault: vi.fn(),
|
setCustomerChargeRuleDefault: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
vi.mock('@/modules/iam', () => ({ getPlatformTenantDimensionInitializationStatus: vi.fn() }))
|
||||||
|
|
||||||
const boxes = [
|
const boxes = [
|
||||||
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
|
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
|
||||||
@@ -64,6 +66,10 @@ describe('CustomerBillingRulesPanel', () => {
|
|||||||
vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue(config)
|
vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue(config)
|
||||||
vi.mocked(listCustomerChargeRules).mockResolvedValue([chargeRule])
|
vi.mocked(listCustomerChargeRules).mockResolvedValue([chargeRule])
|
||||||
vi.mocked(createCustomerChargeRule).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 () => {
|
it('renders the four fixed carton dimension rows and calculated values', async () => {
|
||||||
@@ -111,6 +117,24 @@ describe('CustomerBillingRulesPanel', () => {
|
|||||||
expect(listCustomerChargeRules).not.toHaveBeenCalled()
|
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 () => {
|
it('submits all four rows in one update request', async () => {
|
||||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { Pencil, Ruler, Save } from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
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 AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import { createCustomerChargeRule, listCustomerChargeRules, setCustomerChargeRuleDefault, type CustomerChargeRule, type CreateCustomerChargeRuleRequest } from '@/modules/fba-pricing'
|
import { createCustomerChargeRule, listCustomerChargeRules, setCustomerChargeRuleDefault, type CustomerChargeRule, type CreateCustomerChargeRuleRequest } from '@/modules/fba-pricing'
|
||||||
|
import { getPlatformTenantDimensionInitializationStatus } from '@/modules/iam'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
customerId: string
|
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 canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
|
||||||
const canChargeView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
|
const canChargeView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
|
||||||
const canChargeEdit = computed(() => canChargeView.value && access.can('firstleg:fba-pricing:rule:edit'))
|
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 initializationPending = computed(() => ['PENDING', 'SENDING'].includes(initializationStatus.value))
|
||||||
const initializationFailed = computed(() => initializationStatus.value === 'FAILED')
|
const initializationFailed = computed(() => initializationStatus.value === 'FAILED')
|
||||||
const initializationReady = computed(() => !initializationPending.value && !initializationFailed.value)
|
const initializationReady = computed(() => !initializationPending.value && !initializationFailed.value)
|
||||||
@@ -43,6 +45,8 @@ const editorDirty = ref(false)
|
|||||||
const defaultCalculationMode = ref<'ACTUAL' | 'DIMENSION' | 'WEIGHT'>('ACTUAL')
|
const defaultCalculationMode = ref<'ACTUAL' | 'DIMENSION' | 'WEIGHT'>('ACTUAL')
|
||||||
const defaultModeSaving = ref(false)
|
const defaultModeSaving = ref(false)
|
||||||
const editorForm = ref<FormInstance>()
|
const editorForm = ref<FormInstance>()
|
||||||
|
let initializationPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let initializationPollGeneration = 0
|
||||||
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
|
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
|
||||||
const chargeRules = ref<CustomerChargeRule[]>([])
|
const chargeRules = ref<CustomerChargeRule[]>([])
|
||||||
const visibleChargeRules = computed(() => chargeRules.value.filter((rule) => rule.status === 'ACTIVE'))
|
const visibleChargeRules = computed(() => chargeRules.value.filter((rule) => rule.status === 'ACTIVE'))
|
||||||
@@ -99,14 +103,24 @@ watch(form, () => {
|
|||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
watch(() => props.customerId, () => {
|
watch(() => props.customerId, () => {
|
||||||
|
stopInitializationPolling()
|
||||||
|
observedInitializationStatus.value = props.dimensionInitializationStatus?.trim().toUpperCase() || 'READY'
|
||||||
editorVisible.value = false
|
editorVisible.value = false
|
||||||
defaultCalculationMode.value = 'ACTUAL'
|
defaultCalculationMode.value = 'ACTUAL'
|
||||||
if (initializationReady.value) {
|
if (initializationReady.value) {
|
||||||
void loadConfig()
|
void loadConfig()
|
||||||
void loadChargeRules()
|
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) => {
|
watch(initializationReady, (ready, previous) => {
|
||||||
if (ready && !previous) {
|
if (ready && !previous) {
|
||||||
void loadConfig()
|
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[]) {
|
function sortRules(rules: CustomerDimensionRule[]) {
|
||||||
return [...rules].sort((a, b) => BOX_ORDER.indexOf(a.boxType) - BOX_ORDER.indexOf(b.boxType))
|
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 }))
|
.map((rule) => ({ ...rule, boxTypeName: BOX_NAMES[rule.boxType] || rule.boxTypeName }))
|
||||||
@@ -307,10 +347,16 @@ async function loadChargeRules() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (initializationPending.value) {
|
||||||
|
startInitializationPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!initializationReady.value) return
|
if (!initializationReady.value) return
|
||||||
void loadConfig()
|
void loadConfig()
|
||||||
void loadChargeRules()
|
void loadChargeRules()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(stopInitializationPolling)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -318,7 +364,7 @@ onMounted(() => {
|
|||||||
<div v-if="initializationPending" class="rule-state initialization-state" role="status">
|
<div v-if="initializationPending" class="rule-state initialization-state" role="status">
|
||||||
<Ruler :size="30" />
|
<Ruler :size="30" />
|
||||||
<strong>客户计费规则正在初始化</strong>
|
<strong>客户计费规则正在初始化</strong>
|
||||||
<p>默认箱规和权重系数正在后台准备,请稍后重新加载客户详情。</p>
|
<p>默认箱规和权重系数正在后台准备,完成后本页会自动加载。</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="initializationFailed" class="rule-error initialization-state" role="alert">
|
<div v-else-if="initializationFailed" class="rule-error initialization-state" role="alert">
|
||||||
<div><strong>客户计费规则初始化失败</strong><p>请在客户管理中重试初始化,成功后再查看计费规则。</p></div>
|
<div><strong>客户计费规则初始化失败</strong><p>请在客户管理中重试初始化,成功后再查看计费规则。</p></div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
deletePlatformPermission,
|
deletePlatformPermission,
|
||||||
deletePlatformRole,
|
deletePlatformRole,
|
||||||
downloadTenantQualification,
|
downloadTenantQualification,
|
||||||
|
getPlatformTenantDimensionInitializationStatus,
|
||||||
listPlatformTenants,
|
listPlatformTenants,
|
||||||
listPlatformTenantOrderCounts,
|
listPlatformTenantOrderCounts,
|
||||||
listTenantQualifications,
|
listTenantQualifications,
|
||||||
@@ -400,6 +401,20 @@ describe('platform IAM roles', () => {
|
|||||||
expect(form.get('replaceLegalPersonId')).toBe('true')
|
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 () => {
|
it('omits the optional legal-person ID parts when only the business license is uploaded', async () => {
|
||||||
const result = {
|
const result = {
|
||||||
businessLicenseQualificationId: '91', legalPersonIdQualificationId: null,
|
businessLicenseQualificationId: '91', legalPersonIdQualificationId: null,
|
||||||
|
|||||||
@@ -148,6 +148,16 @@ export interface PlatformTenantCreationResponse {
|
|||||||
temporaryPassword: string
|
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 {
|
export interface ResetPlatformTenantOwnerPasswordRequest {
|
||||||
newPassword: string
|
newPassword: string
|
||||||
forceChangePassword: boolean
|
forceChangePassword: boolean
|
||||||
@@ -364,6 +374,12 @@ export function getPlatformTenant(tenantId: number | string) {
|
|||||||
return getData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}`)
|
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) {
|
export async function checkPlatformTenantContactPhone(contactPhone: string, excludeTenantId?: number | string) {
|
||||||
const response = await http.get<ApiEnvelope<boolean>, ApiEnvelope<boolean>>(
|
const response = await http.get<ApiEnvelope<boolean>, ApiEnvelope<boolean>>(
|
||||||
'/platform/v1/iam/tenants/contact-phone-availability',
|
'/platform/v1/iam/tenants/contact-phone-availability',
|
||||||
|
|||||||
Reference in New Issue
Block a user