diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index 785aca13..812dfa27 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -13,6 +13,7 @@ import { createPlatformTenantImpersonation, downloadTenantQualification, getPlatformTenant, + getPlatformTenantDimensionInitializationStatus, listEnterpriseReviews, listPlatformTenantOrderCounts, listPlatformTenants, @@ -32,6 +33,14 @@ import { type PlatformTenantResource, type PlatformTenantStatus, } from '@/modules/iam' +import { + getCustomerDimensionConfig, + updateCustomerDimensionConfig, + updateCustomerDimensionDefaultCalculationMode, + type CustomerDimensionBoxType, + type CustomerDimensionCalculationMode, +} from '@/modules/dimension-config' +import { createCustomerChargeRule, setCustomerChargeRuleDefault } from '@/modules/fba-pricing' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import CopyButton from '@/shared/components/CopyButton.vue' @@ -136,6 +145,25 @@ const queryFields: QueryField[] = [ ] type CustomerForm = CreatePlatformTenantRequest & { unifiedSocialCreditCode: string } +type CreationBillingRow = { + boxType: CustomerDimensionBoxType + boxTypeName: string + innerLengthCm: number + innerWidthCm: number + innerHeightCm: number + edgeAdditionCm: number + coefficient: number + weight: number + remark: string +} + +const newCreationBillingRows = (): CreationBillingRow[] => [ + { boxType: 'SMALL', boxTypeName: '小箱', innerLengthCm: 30, innerWidthCm: 20, innerHeightCm: 20, edgeAdditionCm: 2, coefficient: 1.1, weight: 5, remark: '标准小箱' }, + { boxType: 'MEDIUM', boxTypeName: '中箱', innerLengthCm: 40, innerWidthCm: 30, innerHeightCm: 30, edgeAdditionCm: 2, coefficient: 1.1, weight: 8, remark: '标准中箱' }, + { boxType: 'LARGE', boxTypeName: '大箱', innerLengthCm: 50, innerWidthCm: 40, innerHeightCm: 40, edgeAdditionCm: 2, coefficient: 1.1, weight: 12, remark: '标准大箱' }, + { boxType: 'EXTRA_LARGE', boxTypeName: '特大箱', innerLengthCm: 60, innerWidthCm: 50, innerHeightCm: 50, edgeAdditionCm: 2, coefficient: 1.1, weight: 15, remark: '特大箱' }, +] + const emptyForm = (): CustomerForm => ({ tenantName: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '', contactEmail: '', contactWechat: '', servicePackage: '基础版', salesOwnerName: '', paymentTermDays: 30, @@ -143,6 +171,11 @@ const emptyForm = (): CustomerForm => ({ registeredAddress: '', internalRemark: '', }) const form = ref(emptyForm()) +const creationBillingEnabled = ref(false) +const creationBillingDefaultMode = ref>('ADD') +const creationBillingRows = ref(newCreationBillingRows()) +const creationBillingSession = ref(false) +const savedCreationBillingSnapshot = ref('') const rules: FormRules = { tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }], legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }], @@ -216,7 +249,9 @@ let editorLoadSequence = 0 const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户') const editorDialogWidth = computed(() => editingId.value ? 'min(640px, calc(100vw - 28px))' - : 'min(680px, calc(100vw - 28px))') + : 'min(1120px, calc(100vw - 28px))') +const canConfigureCreationBilling = computed(() => access.can('firstleg:dimension-config:edit') + && access.can('firstleg:fba-pricing:rule:edit')) const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value)) const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || '')) const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading)) @@ -386,12 +421,110 @@ function clearQualificationPreviews() { function resetEditor() { clearQualificationPreviews() form.value = emptyForm(); qualifications.value = newQualificationDrafts(); creditCodeEstablished.value = false; qualificationLoadError.value = ''; savedFormSnapshot.value = '' + creationBillingEnabled.value = false + creationBillingDefaultMode.value = 'ADD' + creationBillingRows.value = newCreationBillingRows() + creationBillingSession.value = false + savedCreationBillingSnapshot.value = '' } function openCreate() { editorLoadSequence += 1 - editingId.value = null; editorStatus.value = null; resetEditor(); editorOpen.value = true + editingId.value = null; editorStatus.value = null; resetEditor(); creationBillingSession.value = true; editorOpen.value = true nextTick(() => formRef.value?.clearValidate()) } + +function creationBillingSnapshot() { + return JSON.stringify({ + enabled: creationBillingEnabled.value, + defaultMode: creationBillingDefaultMode.value, + rows: creationBillingRows.value, + }) +} + +function validateCreationBillingRules() { + if (!creationBillingEnabled.value) return true + if (!canConfigureCreationBilling.value) { + ElMessage.error('当前账号缺少箱规尺寸或计费规则编辑权限,无法随客户一并保存') + return false + } + if (creationBillingRows.value.some((row) => ![ + row.innerLengthCm, row.innerWidthCm, row.innerHeightCm, + ].every((value) => Number.isFinite(value) && value >= 0.001))) { + ElMessage.error('四档箱规的内径长、宽、高必须大于 0') + return false + } + if (creationBillingRows.value.some((row) => !Number.isFinite(row.edgeAdditionCm) + || row.edgeAdditionCm < 0.001 || row.edgeAdditionCm > 100)) { + ElMessage.error('四档箱规的单边加宽必须在 0.001 至 100cm 之间') + return false + } + if (creationBillingRows.value.some((row) => !Number.isFinite(row.coefficient) || row.coefficient < 0.001)) { + ElMessage.error('四档箱规的权重系数必须大于 0') + return false + } + if (creationBillingRows.value.some((row) => !Number.isFinite(row.weight) || row.weight < 0.001)) { + ElMessage.error('四档箱规的重量必须大于 0') + return false + } + return true +} + +function wait(milliseconds: number) { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)) +} + +async function waitForDimensionInitialization(tenantId: string) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const status = await getPlatformTenantDimensionInitializationStatus(tenantId) + if (status.status === 'READY') return + if (status.status === 'FAILED') { + throw new Error(status.lastErrorMessage || '客户默认箱规初始化失败') + } + await wait(500) + } + throw new Error('客户默认箱规仍在初始化,请稍后再次点击保存') +} + +async function saveCreationBillingRules(tenantId: string, customerName: string) { + if (!creationBillingEnabled.value || creationBillingSnapshot() === savedCreationBillingSnapshot.value) return + await waitForDimensionInitialization(tenantId) + const current = await getCustomerDimensionConfig(tenantId) + const versions = new Map(current.rules.map((rule) => [rule.boxType, rule.version])) + await updateCustomerDimensionConfig(tenantId, { + rules: creationBillingRows.value.map((row) => ({ + boxType: row.boxType, + innerLengthCm: row.innerLengthCm, + innerWidthCm: row.innerWidthCm, + innerHeightCm: row.innerHeightCm, + edgeAdditionCm: row.edgeAdditionCm, + remark: row.remark || null, + version: versions.get(row.boxType) ?? 0, + })), + }) + const firstCoefficient = creationBillingRows.value[0].coefficient + const created = await createCustomerChargeRule(tenantId, { + ruleCode: `RULE-${Date.now()}`, + customerId: tenantId, + customerName, + businessType: 'FBA', + chargeMode: 'CBM', + taxMode: 'TAX_INCLUDED', + conversionFactor: 167, + lengthFactor: firstCoefficient, + widthFactor: firstCoefficient, + heightFactor: firstCoefficient, + defaultRule: false, + roundingScale: 2, + roundingMode: 'HALF_UP', + currency: 'CNY', + effectiveFrom: new Date().toISOString().slice(0, 19), + effectiveTo: null, + remark: creationBillingRows.value.map((row) => `${row.boxType}:${row.innerLengthCm},${row.innerWidthCm},${row.innerHeightCm};系数:${row.coefficient};重量:${row.weight};${row.boxTypeName}:${row.remark || '—'}`).join(';'), + }) + await setCustomerChargeRuleDefault(tenantId, created.id) + await updateCustomerDimensionDefaultCalculationMode(tenantId, creationBillingDefaultMode.value) + savedCreationBillingSnapshot.value = creationBillingSnapshot() +} function mapTenantToForm(row: PlatformTenantResource): CustomerForm { return { tenantName: row.tenantName, legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '', @@ -562,6 +695,7 @@ async function uploadSelectedQualifications(tenantId: string) { async function saveCustomer() { if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return + if (!validateCreationBillingRules()) return if (!requiredQualificationPackageComplete.value) { ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`) return @@ -592,6 +726,15 @@ async function saveCustomer() { editorStatus.value = saved.status savedFormSnapshot.value = JSON.stringify(form.value) const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : [] + if (creationBillingSession.value && creationBillingEnabled.value) { + try { + await saveCreationBillingRules(String(saved.id), saved.tenantName) + } catch (error) { + ElMessage.error(`客户资料已保存,但箱规尺寸和权重系数未完整保存:${getApiErrorMessage(error, '请稍后再次点击保存')}`) + await loadCustomers() + return + } + } if (failures.length) { ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`) } else if (hadBusinessLicense && hadLegalPersonId) { @@ -1090,6 +1233,26 @@ onMounted(() => { void loadCustomers() }) + +
+ 按箱规尺寸计算 + + 箱规尺寸 + 权重系数 + +
+

当前账号需同时具备箱规尺寸和客户计费规则编辑权限,才能在创建时配置。

+ +
+
{{ statusMeta(editorStatus).label }}
{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }} @@ -1235,6 +1398,7 @@ onMounted(() => { void loadCustomers() }) :deep(.customer-list-table .el-tag--danger) { background:linear-gradient(135deg,rgba(239,68,68,.15),rgba(220,38,38,.1)); color:#dc2626; } :deep(.customer-list-table .el-tag--info) { background:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; } .capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; } +.creation-billing-heading { display:flex; min-height:36px; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; } .creation-billing-heading :deep(.el-checkbox) { margin-right:0; font-weight:650; } .creation-billing-hint,.creation-billing-permission-hint { margin:8px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .creation-billing-permission-hint { color:var(--el-color-warning); } .creation-billing-table-wrap { overflow-x:auto; margin-top:12px; border:1px solid var(--cc-border); border-radius:8px; } .creation-billing-table { width:100%; min-width:1040px; border-collapse:collapse; } .creation-billing-table th,.creation-billing-table td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; white-space:nowrap; } .creation-billing-table thead th { background:#f7f8fc; color:var(--cc-muted); font-size:12px; } .creation-billing-table tbody tr:last-child th,.creation-billing-table tbody tr:last-child td { border-bottom:0; } .creation-billing-table tbody th { color:var(--cc-text); font-size:13px; } .creation-billing-table :deep(.el-input-number) { width:112px; } .creation-billing-table :deep(.el-input) { width:130px; } .qualification-heading { display:flex; justify-content:flex-end; margin-bottom:12px; } .qualification-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); } .qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small,.field-hint { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; } .qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; } diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index 2ff8f2c8..4d8aade7 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -147,6 +147,16 @@ export interface PlatformTenantCreationResponse { temporaryPassword: string } +export interface CustomerDimensionInitializationStatus { + tenantId: number | string + status: 'PENDING' | 'READY' | 'FAILED' + attemptCount: number + nextRetryAt: string | null + lastErrorCode: string | null + lastErrorMessage: string | null + updatedAt: string | null +} + export interface ResetPlatformTenantOwnerPasswordRequest { newPassword: string forceChangePassword: boolean @@ -363,6 +373,10 @@ export function getPlatformTenant(tenantId: number | string) { return getData(`/platform/v1/iam/tenants/${tenantId}`) } +export function getPlatformTenantDimensionInitializationStatus(tenantId: number | string) { + return getData(`/platform/v1/iam/tenants/${tenantId}/dimension-initialization`) +} + export async function checkPlatformTenantContactPhone(contactPhone: string, excludeTenantId?: number | string) { const response = await http.get, ApiEnvelope>( '/platform/v1/iam/tenants/contact-phone-availability', diff --git a/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts b/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts index bd53ff77..e8599c99 100644 --- a/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts +++ b/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries' +import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries' vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http') @@ -61,4 +61,11 @@ describe('platformCarrierScheduleQueries', () => { await appendCarrierScheduleManualNode('9007199254740993', 'manual-session-key', request) expect(vi.mocked(http.post)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993/manual-nodes', request, { headers: { 'Idempotency-Key': 'manual-session-key' } }) }) + + it('ensures a missing departed-container task before manual entry', async () => { + const detail = { query: { query: { ...query, customerId: 0 }, expired: false }, nodes: [], exceptions: [], histories: [] } + vi.mocked(http.post).mockResolvedValue({ code: '200', data: detail }) + await expect(ensureCarrierScheduleQueryForContainer('303', 'ensure-key')).resolves.toMatchObject({ query: { query: { customerId: '0', containerBookingId: '303' } } }) + expect(vi.mocked(http.post)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/containers/303/ensure', null, { headers: { 'Idempotency-Key': 'ensure-key' } }) + }) }) diff --git a/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts b/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts index f3a51a72..bb62e2d9 100644 --- a/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts +++ b/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts @@ -56,6 +56,16 @@ export async function getCarrierScheduleQuery(queryId: string): Promise { + const response = await http.post, ApiEnvelope>( + `${basePath}/containers/${encodeURIComponent(containerBookingId)}/ensure`, + null, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + const result = unwrapApiResponse(response) + return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), exceptions: (result.exceptions || []).map(exception), histories: (result.histories || []).map(history) } +} + export async function appendCarrierScheduleManualNode(queryId: string, idempotencyKey: string, request: CarrierScheduleManualNodeRequest): Promise { const response = await http.post, ApiEnvelope>( `${basePath}/${encodeURIComponent(queryId)}/manual-nodes`, diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts index a34f5e6a..5c48ce46 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts @@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils' import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue' -import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries' +import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries' import { getPortSchedule } from '../api/platformPortSchedules' import { useAuthStore } from '@/core/auth/auth' import { ApiResponseError } from '@/shared/api/http' @@ -11,6 +11,7 @@ import { ApiResponseError } from '@/shared/api/http' vi.mock('../api/platformCarrierScheduleQueries', () => ({ listCarrierScheduleQueries: vi.fn(), getCarrierScheduleQuery: vi.fn(), + ensureCarrierScheduleQueryForContainer: vi.fn(), appendCarrierScheduleManualNode: vi.fn(), })) vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() })) @@ -63,6 +64,21 @@ describe('CarrierScheduleQueryPanel loading states', () => { expect(getPortSchedule).not.toHaveBeenCalled() }) + it('shows manual entry without an existing task and ensures the task on click', async () => { + vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) + vi.mocked(ensureCarrierScheduleQueryForContainer).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, customerId: '0', resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] }) + const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } }) + await flushPromises() + + const button = wrapper.findAll('button').find((item) => item.text().includes('人工补录节点')) + expect(button).toBeTruthy() + await button!.trigger('click') + await flushPromises() + + expect(ensureCarrierScheduleQueryForContainer).toHaveBeenCalledWith('3', expect.any(String)) + expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true) + }) + it('filters large no-op status batches, keeps real actions, deduplicates only adjacent explicit event keys and sorts newest first', async () => { vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) const noops = Array.from({ length: 40 }, (_, index) => ({ diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue index d91c7598..237f600f 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue @@ -6,7 +6,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' -import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries' +import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries' import { getPortSchedule } from '../api/platformPortSchedules' import type { CarrierScheduleExceptionFact, CarrierScheduleManualNodeRequest, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types' @@ -31,6 +31,8 @@ const manualDialogOpen = ref(false) const manualIdempotencyKey = ref('') const manualObservedAt = ref('') const manualSaving = ref(false) +const manualPreparing = ref(false) +const manualEntryError = ref(null) const manualError = ref(null) const manualValidationError = ref('') const manualForm = reactive({ standardNode: '' as CarrierScheduleStandardNode | '', estimatedAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' }) @@ -205,7 +207,24 @@ function localDateTimeNow() { const now = new Date() return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19) } -function openManualDialog() { resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true } +async function openManualDialog() { + if (manualPreparing.value) return + manualEntryError.value = null + if (!query.value) { + if (!props.containerBookingId) { manualEntryError.value = getApiErrorDetails(new Error('当前页面缺少货柜信息,无法建立船节点任务')); return } + manualPreparing.value = true + try { + const ensured = await ensureCarrierScheduleQueryForContainer(props.containerBookingId, createIdempotencyKey()) + detail.value = ensured + selected.value = ensured.query + queryDetailError.value = null + } catch (cause) { + manualEntryError.value = getApiErrorDetails(cause, '船节点任务建立失败') + return + } finally { manualPreparing.value = false } + } + resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true +} async function submitManualNode() { manualValidationError.value = '' if (!query.value || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return } @@ -254,11 +273,12 @@ watch(() => manualForm.actualAt, (actualAt) => {