diff --git a/src/app/layouts/AppLayout.test.ts b/src/app/layouts/AppLayout.test.ts index 3d9fa373..8accc030 100644 --- a/src/app/layouts/AppLayout.test.ts +++ b/src/app/layouts/AppLayout.test.ts @@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia' import { flushPromises, mount } from '@vue/test-utils' import { defineComponent, ref } from 'vue' import { createMemoryHistory, createRouter } from 'vue-router' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ElMessageBox } from 'element-plus' import { useAuthStore } from '@/core/auth/auth' import AppLayout from './AppLayout.vue' @@ -44,8 +44,53 @@ async function mountLayout(path = '/') { return { wrapper, router } } +function mockViewport(isMobile: boolean) { + const mediaQuery = { + matches: isMobile, + media: '(max-width: 720px)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + } as unknown as MediaQueryList + vi.stubGlobal('matchMedia', vi.fn(() => mediaQuery)) +} + describe('AppLayout', () => { - beforeEach(() => { vi.mocked(ElMessageBox.confirm).mockReset() }) + beforeEach(() => { + vi.mocked(ElMessageBox.confirm).mockReset() + mockViewport(false) + }) + afterEach(() => vi.unstubAllGlobals()) + + it('reports the desktop navigation expanded and collapsed states', async () => { + const { wrapper } = await mountLayout() + const menuButton = wrapper.get('.shell-menu-button') + + expect(menuButton.attributes('aria-label')).toBe('收起导航') + expect(menuButton.attributes('aria-expanded')).toBe('true') + + await menuButton.trigger('click') + + expect(menuButton.attributes('aria-label')).toBe('展开导航') + expect(menuButton.attributes('aria-expanded')).toBe('false') + }) + + it('reports the mobile menu closed and open states', async () => { + mockViewport(true) + const { wrapper } = await mountLayout() + const menuButton = wrapper.get('.shell-menu-button') + + expect(menuButton.attributes('aria-label')).toBe('打开菜单') + expect(menuButton.attributes('aria-expanded')).toBe('false') + + await menuButton.trigger('click') + + expect(menuButton.attributes('aria-label')).toBe('关闭菜单') + expect(menuButton.attributes('aria-expanded')).toBe('true') + }) it('shows the authenticated identity without a role preview switcher', async () => { const { wrapper } = await mountLayout() diff --git a/src/app/layouts/AppLayout.vue b/src/app/layouts/AppLayout.vue index b5c36aeb..e204ee84 100644 --- a/src/app/layouts/AppLayout.vue +++ b/src/app/layouts/AppLayout.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index 42164874..497d6478 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -177,6 +177,14 @@ describe('InboundFeePanel', () => { expect(document.querySelector('[data-testid="adjust-fee-button"]')).not.toBeNull() }) + it('allows adjustment for a calculated fee waiting for split', async () => { + vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' }) + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true) + }) + it('submits a reasoned adjustment and emits the refreshed snapshot', async () => { const wrapper = mountPanel() await flushPromises() @@ -184,12 +192,15 @@ describe('InboundFeePanel', () => { await flushPromises() const numbers = [...document.querySelectorAll('.adjustment-form input')] - numbers[0]!.value = '1200' + numbers[0]!.value = '140' numbers[0]!.dispatchEvent(new Event('input', { bubbles: true })) numbers[0]!.dispatchEvent(new Event('change', { bubbles: true })) - numbers[1]!.value = '80' + numbers[1]!.value = '8.2' numbers[1]!.dispatchEvent(new Event('input', { bubbles: true })) numbers[1]!.dispatchEvent(new Event('change', { bubbles: true })) + numbers[2]!.value = '80' + numbers[2]!.dispatchEvent(new Event('input', { bubbles: true })) + numbers[2]!.dispatchEvent(new Event('change', { bubbles: true })) const reason = document.querySelector('.adjustment-form textarea')! reason.value = '补充已确认的附加费用' reason.dispatchEvent(new Event('input', { bubbles: true })) @@ -203,7 +214,7 @@ describe('InboundFeePanel', () => { expect(adjustPublishedInboundFee).toHaveBeenCalledWith( '10001', '88', - { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费用' }, + { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用' }, expect.stringMatching(/^fee-adjust-/), ) expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 }) @@ -219,10 +230,12 @@ describe('InboundFeePanel', () => { await flushPromises() const numbers = [...document.querySelectorAll('.adjustment-form input')] - numbers[0]!.value = '1200' + numbers[0]!.value = '140' numbers[0]!.dispatchEvent(new Event('input', { bubbles: true })) - numbers[1]!.value = '80' + numbers[1]!.value = '8.2' numbers[1]!.dispatchEvent(new Event('input', { bubbles: true })) + numbers[2]!.value = '80' + numbers[2]!.dispatchEvent(new Event('input', { bubbles: true })) const reason = document.querySelector('.adjustment-form textarea')! reason.value = '并发场景价格修正' reason.dispatchEvent(new Event('input', { bubbles: true })) diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index 67a2888f..487e7742 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -43,13 +43,16 @@ let adjustmentFingerprint = '' let adjustmentIdempotencyKey = '' const adjustmentForm = reactive({ - subtotalAmount: 0, + expectedFeeVersion: 0, + chargeableQuantity: 0, + unitPrice: 0, additionalAmount: 0, reason: '', }) const adjustmentRules: FormRules = { - subtotalAmount: [{ required: true, message: '请输入调整后的基础运费', trigger: 'change' }], + chargeableQuantity: [{ required: true, message: '请输入调整后的计费量', trigger: 'change' }], + unitPrice: [{ required: true, message: '请输入调整后的单价', trigger: 'change' }], additionalAmount: [{ required: true, message: '请输入调整后的附加费', trigger: 'change' }], reason: [ { required: true, whitespace: true, message: '请填写费用调整原因', trigger: 'blur' }, @@ -61,8 +64,8 @@ const canView = computed(() => access.can('firstleg:fba-pricing:view')) const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust')) const customerContextId = computed(() => props.customerId || props.tenantId || '') const adjustmentEligible = computed(() => ( - snapshot.value?.feeStatus === 'SENT' - && snapshot.value.nextProcessStatus === 'WAIT_SPLIT' + ['CALCULATED', 'CONFIRMED', 'SENT'].includes(snapshot.value?.feeStatus || '') + && snapshot.value?.nextProcessStatus === 'WAIT_SPLIT' )) const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费') const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM') @@ -142,7 +145,9 @@ async function loadFee(options: { preserveAdjustment?: boolean } = {}) { } function fillAdjustmentForm(value: InboundFeeSnapshot) { - adjustmentForm.subtotalAmount = Number(value.subtotalAmount) + adjustmentForm.expectedFeeVersion = Number(value.feeVersion) + adjustmentForm.chargeableQuantity = Number(value.chargeableQuantity) + adjustmentForm.unitPrice = Number(value.unitPrice) adjustmentForm.additionalAmount = Number(value.additionalAmount) adjustmentForm.reason = '' adjustmentError.value = null @@ -159,7 +164,9 @@ function openAdjustment() { function adjustmentPayload(): AdjustPublishedInboundFeeRequest { return { - subtotalAmount: Number(adjustmentForm.subtotalAmount), + expectedFeeVersion: adjustmentForm.expectedFeeVersion, + chargeableQuantity: Number(adjustmentForm.chargeableQuantity), + unitPrice: Number(adjustmentForm.unitPrice), additionalAmount: Number(adjustmentForm.additionalAmount), reason: adjustmentForm.reason.trim(), } @@ -244,7 +251,7 @@ defineExpose({ reload: loadFee }) data-testid="adjust-fee-button" type="primary" @click="openAdjustment" - >调整已发布费用 + >调整初算费用 @@ -343,14 +350,14 @@ defineExpose({ reload: loadFee }) - +

当前输入仍保留,请先核对最新版本后再提交。

@@ -362,16 +369,20 @@ defineExpose({ reload: loadFee })
- - - {{ snapshot?.currency }} + + + {{ chargeUnitLabel }} + + + + {{ snapshot?.currency }} / {{ chargeUnitLabel }} {{ snapshot?.currency }}
-
调整后总额{{ formatMoney(adjustmentForm.subtotalAmount + adjustmentForm.additionalAmount, snapshot?.currency) }}
+
当前服务端计算金额{{ formatMoney(snapshot?.totalAmount, snapshot?.currency) }}提交后以服务端返回的新版本金额为准
diff --git a/src/modules/inbounds/fee/api.test.ts b/src/modules/inbounds/fee/api.test.ts index 3ad2d856..08c67621 100644 --- a/src/modules/inbounds/fee/api.test.ts +++ b/src/modules/inbounds/fee/api.test.ts @@ -34,7 +34,7 @@ describe('inbound fee API', () => { it('adjusts a published snapshot with the required idempotency key', async () => { vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } }) - const payload = { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费' } + const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费' } await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1')) .resolves.toMatchObject({ feeVersion: 3 }) @@ -54,7 +54,7 @@ describe('inbound fee API', () => { const error = await adjustPublishedInboundFee( '10001', '88', - { subtotalAmount: 1200, additionalAmount: 80, reason: '价格修正' }, + { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正' }, 'fee-adjust-2', ).catch((reason: unknown) => reason) diff --git a/src/modules/inbounds/fee/types.ts b/src/modules/inbounds/fee/types.ts index cf73305d..f2c6583d 100644 --- a/src/modules/inbounds/fee/types.ts +++ b/src/modules/inbounds/fee/types.ts @@ -47,7 +47,9 @@ export interface InboundFeeSnapshot { } export interface AdjustPublishedInboundFeeRequest { - subtotalAmount: number + expectedFeeVersion: number + chargeableQuantity: number + unitPrice: number additionalAmount: number reason: string } diff --git a/src/modules/inbounds/pages/InboundOrdersView.test.ts b/src/modules/inbounds/pages/InboundOrdersView.test.ts index 5008d7ba..61151bd9 100644 --- a/src/modules/inbounds/pages/InboundOrdersView.test.ts +++ b/src/modules/inbounds/pages/InboundOrdersView.test.ts @@ -110,6 +110,9 @@ describe('InboundOrdersView', () => { expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange + 1) expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ page: 1, size: 20 })) expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }) + const customerQueryField = (toolbar.props('fields') as Array<{ key: string; type?: string; props?: Record }>).find((field) => field.key === 'customerId') + expect(customerQueryField?.type).toBe('select') + expect(customerQueryField?.props?.filterable).toBe(true) expect(document.body.textContent).toContain('RK-001') expect(document.body.textContent).toContain('查看') expect(document.body.textContent).toContain('示例企业有限公司') @@ -117,6 +120,18 @@ describe('InboundOrdersView', () => { expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部') }) + it('keeps the customer filter name-only and does not add a customer-code input', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrdersView.vue'), 'utf8') + const queryFieldsSource = source.slice(source.indexOf('const allQueryFields'), source.indexOf('const columns')) + + expect(queryFieldsSource).toContain("key: 'customerId'") + expect(queryFieldsSource).toContain("type: 'select'") + expect(queryFieldsSource).toContain('filterable: true') + expect(queryFieldsSource).toContain('legalName?.trim() || customer.tenantName') + expect(queryFieldsSource).not.toContain('tenantCode') + expect(queryFieldsSource).not.toContain('客户编码') + }) + it('uses backend-supported warehouse IDs from tenant-scoped options without coercing Long IDs', async () => { const wrapper = mountPage() const toolbar = await selectCustomer(wrapper) diff --git a/src/modules/inbounds/pages/InboundOrdersView.vue b/src/modules/inbounds/pages/InboundOrdersView.vue index 854e6991..fea603d8 100644 --- a/src/modules/inbounds/pages/InboundOrdersView.vue +++ b/src/modules/inbounds/pages/InboundOrdersView.vue @@ -65,7 +65,7 @@ const tabs: Array<{ value: InboundListTab | ''; label: string; description: stri { value: 'WAIT_MEASURE', label: '待测量', description: '等待测量或补正' }, { value: 'WAIT_REVIEW', label: '待审核', description: '等待测量审核' }, { value: 'WAIT_FEE_SEND', label: '待费用/入库', description: '等待费用编排完成' }, - { value: 'WAIT_SPLIT', label: '待分柜', description: '费用已发布并完成入库,等待分柜' }, + { value: 'WAIT_SPLIT', label: '待分柜', description: '测量审核完成,等待分柜;费用可继续维护' }, ] const advancedFilterKeys = new Set(['orderStatus', 'feeReviewStatus', 'customerName', 'destinationCode', 'holdFlag']) diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.test.ts b/src/modules/inbounds/review/MeasurementReviewPanel.test.ts index 8e04bda2..60eb57de 100644 --- a/src/modules/inbounds/review/MeasurementReviewPanel.test.ts +++ b/src/modules/inbounds/review/MeasurementReviewPanel.test.ts @@ -55,7 +55,7 @@ describe('MeasurementReviewPanel', () => { expect(wrapper.text()).toContain('调整原因:去除托盘重量后采用净值') expect(wrapper.findComponent(DataTable).exists()).toBe(true) expect(wrapper.text()).not.toContain('租户') - expect(wrapper.text()).toContain('通过并完成入库') + expect(wrapper.text()).toContain('通过并进入待分柜') expect(wrapper.text()).not.toContain('确认费用') expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001') const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default) @@ -77,25 +77,37 @@ describe('MeasurementReviewPanel', () => { }) it('emits changed after a successful review write', async () => { - vi.mocked(approveMeasurement).mockResolvedValue({ feeId: '1', feeNo: 'FEE-1', feeVersion: 1, feeStatus: 'SENT', inboundId: '90001', totalAmount: 100, currency: 'CNY', nextProcessStatus: 'WAIT_SPLIT' }) + vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_SPLIT', feeStatus: 'CALCULATED', initialPricingSucceeded: true, initialPricingMessage: null, initialFee: null }) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) await flushPromises() const buttons = wrapper.findAll('button') - const approve = buttons.find((button) => button.text().includes('通过并完成入库')) + const approve = buttons.find((button) => button.text().includes('通过并进入待分柜')) expect(approve).toBeDefined() await approve?.trigger('click') await flushPromises() expect(ElMessageBox.confirm).toHaveBeenCalledWith( - expect.stringContaining('自动试算并发布费用'), + expect.stringContaining('初算失败不影响审核通过'), '确认测量审核通过', - expect.objectContaining({ confirmButtonText: '通过并完成入库' }), + expect.objectContaining({ confirmButtonText: '通过并进入待分柜' }), ) expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 7 }) expect(wrapper.emitted('changed')).toHaveLength(1) }) + it('keeps approval successful when initial pricing fails', async () => { + vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_SPLIT', feeStatus: 'UNPRICED', initialPricingSucceeded: false, initialPricingMessage: '缺少有效价格', initialFee: null }) + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click') + await flushPromises() + + expect(wrapper.emitted('changed')).toHaveLength(1) + expect(wrapper.text()).toContain('初算失败不阻断审核') + }) + it('rejects with the inbound optimistic-lock version instead of the measurement version', async () => { vi.mocked(rejectMeasurement).mockResolvedValue(undefined) const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.vue b/src/modules/inbounds/review/MeasurementReviewPanel.vue index c2fb80c5..0e179877 100644 --- a/src/modules/inbounds/review/MeasurementReviewPanel.vue +++ b/src/modules/inbounds/review/MeasurementReviewPanel.vue @@ -145,20 +145,24 @@ async function confirmApprove() { if (!detail.value || !isPending.value || !canReview.value) return try { await ElMessageBox.confirm( - '审核通过后,系统会自动试算并发布费用,同时完成托盘库位占用和入库。客户无需再次确认费用。是否继续?', + '审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?', '确认测量审核通过', - { confirmButtonText: '通过并完成入库', cancelButtonText: '返回', type: 'warning' }, + { confirmButtonText: '通过并进入待分柜', cancelButtonText: '返回', type: 'warning' }, ) } catch { return } actionLoading.value = 'approve' try { - await approveMeasurement(customerId.value, props.inboundId, { + const result = await approveMeasurement(customerId.value, props.inboundId, { measurementId: detail.value.measurementId, expectedVersion: detail.value.inboundVersion, }) - ElMessage.success('审核已通过,费用发布和入库正在完成') + if (result.initialPricingSucceeded) { + ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算') + } else { + ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`) + } await reload() emit('changed') } catch (error) { @@ -295,10 +299,10 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
-
审核结论通过后将自动完成计费、费用发布和入库。当前测量已驳回,可修正后重新提交,或按权限申请异常放行。当前记录不在可操作的测量审核状态。
+
审核结论通过后进入待分柜;费用仅做初算,初算失败不阻断审核。当前测量已驳回,可修正后重新提交,或按权限申请异常放行。当前记录不在可操作的测量审核状态。
驳回 - 通过并完成入库 + 通过并进入待分柜 异常放行
diff --git a/src/modules/inbounds/review/api.ts b/src/modules/inbounds/review/api.ts index c9180b15..b5fc77e9 100644 --- a/src/modules/inbounds/review/api.ts +++ b/src/modules/inbounds/review/api.ts @@ -1,7 +1,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { ExceptionReleaseRequest, - FeeSnapshotAfterApprove, + MeasurementApprovalResponse, MeasurementReviewDetail, MeasurementReviewHistoryPage, MeasurementReviewHistoryPayload, @@ -63,7 +63,7 @@ export function approveMeasurement( request: MeasurementReviewRequest, key?: string, ) { - return postReview( + return postReview( `/platform/v1/inbounds/${inboundId}/measurements/approve`, tenantId, request, diff --git a/src/modules/inbounds/review/types.ts b/src/modules/inbounds/review/types.ts index 34cfa9be..fc36e82d 100644 --- a/src/modules/inbounds/review/types.ts +++ b/src/modules/inbounds/review/types.ts @@ -116,6 +116,16 @@ export interface ExceptionReleaseRequest { reason: string } +export interface MeasurementApprovalResponse { + inboundId: string | null + measurementId: string | null + processStatus: 'WAIT_SPLIT' | string + feeStatus: 'CALCULATED' | 'UNPRICED' | string + initialPricingSucceeded: boolean + initialPricingMessage: string | null + initialFee: FeeSnapshotAfterApprove | null +} + export interface FeeSnapshotAfterApprove { feeId: string | null feeNo: string | null diff --git a/src/modules/inbounds/task-list.ts b/src/modules/inbounds/task-list.ts index 71467bb4..de46da39 100644 --- a/src/modules/inbounds/task-list.ts +++ b/src/modules/inbounds/task-list.ts @@ -1,7 +1,34 @@ +import type { PlatformTenantResource } from '@/modules/iam' import type { InboundListQuery } from './core/types' export type InboundTaskMode = 'measurement' | 'review' +export interface ActiveCustomerPage { + records: PlatformTenantResource[] + pages: number +} + +export interface ActiveCustomerQuery { + page: number + size: number + status: 'ACTIVE' +} + +export async function loadAllActiveCustomers( + fetchPage: (query: ActiveCustomerQuery) => Promise, +) { + const records: PlatformTenantResource[] = [] + let page = 1 + let pages = 1 + do { + const response = await fetchPage({ page, size: 200, status: 'ACTIVE' }) + records.push(...response.records) + pages = Math.max(1, response.pages) + page += 1 + } while (page <= pages) + return records +} + export function buildInboundTaskQuery( mode: InboundTaskMode, query: Omit, diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts index 6ab89a4a..8cd1f04b 100644 --- a/src/modules/loading-lists/components/LoadingListsView.test.ts +++ b/src/modules/loading-lists/components/LoadingListsView.test.ts @@ -9,7 +9,7 @@ const preloadDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/loa describe('LoadingListsView', () => { it('filters both list modes by an active name-only customer selector', () => { expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") - expect(source).toContain("customer.legalName?.trim() || customer.tenantName") + expect(source).toContain("label: customer.tenantName") expect(source).toContain("customerId: String(query.customerId || '').trim() || undefined") expect(source).not.toContain('customer.tenantCode') }) diff --git a/src/modules/loading-lists/components/LoadingListsView.vue b/src/modules/loading-lists/components/LoadingListsView.vue index 9a284dfc..25feb772 100644 --- a/src/modules/loading-lists/components/LoadingListsView.vue +++ b/src/modules/loading-lists/components/LoadingListsView.vue @@ -69,7 +69,7 @@ const canView = computed(() => canList.value) const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit')) const canManageCustoms = computed(() => !isPreload.value && access.can('platform:firstleg:customs:manage')) const fields = computed(() => [ - { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.legalName?.trim() || customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, + { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 }, { key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 }, ]) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index 0a5148fd..d594eb9d 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -18,7 +18,7 @@ describe('OceanBookingDetailView interaction contract', () => { it('hides unavailable customers instead of filling the selector with disabled options', () => { expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") - expect(source).toContain('customer.legalName?.trim() || customer.tenantName') + expect(source).toContain(':label="customer.tenantName"') expect(source).not.toContain('customer.tenantCode') expect(source).toContain("customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)") expect(source).toContain('v-for="customer in selectableCustomers"') @@ -26,8 +26,8 @@ describe('OceanBookingDetailView interaction contract', () => { }) it('keeps historical partner names visible when an existing partner is no longer selectable', () => { - expect(source).toContain("form.data.shippingCompanyName || '当前船公司'") - expect(source).toContain("form.data.bookingAgentName || '当前订舱代理'") + expect(source).toContain("form.data.shippingCompanyName || '船公司名称缺失'") + expect(source).toContain("form.data.bookingAgentName || '订舱代理名称缺失'") expect(source).toContain("from '@/modules/master-data'") }) @@ -53,7 +53,8 @@ describe('OceanBookingDetailView interaction contract', () => { expect(source).not.toContain('@update:model-value="(value) =>') expect(source).toContain('v-model="container.capacityCbm"') expect(source).toContain('v-model="container.maxWeightKg"') - expect(source).toContain('releaseOceanBooking(id.value, releaseRequest)') + expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version') + expect(source).toContain('title="确认放舱"') expect(source).toContain('createBlankContainerRequirement()') expect(source).toContain('containerRequirementError(form.data.containerRequirements)') expect(source).toContain('function validateContainerRequirements()') diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index c431dc7d..e8055071 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -102,6 +102,8 @@ const availableActions = computed(() => (actionsByStatus[detail.value?.status || const changeDialogOpen = ref(false) const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' }) +const releaseDialogOpen = ref(false) +const releaseForm = reactive({ billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' }) function statusLabel(status: OceanBookingStatus | string | null | undefined) { return oceanBookingStatusLabel(status) @@ -127,7 +129,7 @@ async function loadOptions() { function selectCustomer(customerId: string) { form.data.customerId = customerId const customer = customers.value.find((item) => String(item.id) === customerId) - form.data.customerName = customer ? customer.legalName?.trim() || customer.tenantName : '' + form.data.customerName = customer?.tenantName || '' } function selectWarehouse(value: unknown) { const warehouseId = String(value || '') @@ -208,6 +210,37 @@ function openChangeDialog() { changeForm.reason = '' changeDialogOpen.value = true } +function openReleaseDialog() { + releaseForm.billOfLadingNo = detail.value?.billOfLadingNo || '' + releaseForm.vesselName = detail.value?.vesselName || '' + releaseForm.voyageNo = detail.value?.voyageNo || '' + releaseForm.estimatedSailingAt = detail.value?.estimatedSailingAt || null + releaseForm.estimatedArrivalAt = detail.value?.estimatedArrivalAt || null + releaseForm.reason = '' + releaseDialogOpen.value = true +} +async function submitRelease() { + if (!detail.value) return + const billOfLadingNo = releaseForm.billOfLadingNo.trim() + const vesselName = releaseForm.vesselName.trim() + const voyageNo = releaseForm.voyageNo.trim() + if (!billOfLadingNo || !vesselName || !voyageNo || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt) { + actionError.value = getApiErrorDetails(new Error('请完整填写提单号、船名、航次、预计开船和预计到港时间')) + return + } + if (releaseForm.estimatedArrivalAt < releaseForm.estimatedSailingAt) { + actionError.value = getApiErrorDetails(new Error('预计到港时间不能早于预计开船时间')) + return + } + actionSaving.value = true + actionError.value = null + try { + applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt: releaseForm.estimatedSailingAt, estimatedArrivalAt: releaseForm.estimatedArrivalAt, reason: releaseForm.reason.trim() || null })) + releaseDialogOpen.value = false + ElMessage.success('确认放舱成功') + } catch (cause) { actionError.value = getApiErrorDetails(cause) } + finally { actionSaving.value = false } +} async function submitChange() { if (!detail.value || !changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()) return actionSaving.value = true @@ -221,16 +254,14 @@ async function submitChange() { } async function runAction(action: OceanBookingAction) { if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return } + if (action === 'release') { openReleaseDialog(); return } actionError.value = null if (!validateContainerRequirements()) return try { const reason = await requiredReason(action) await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }) actionSaving.value = true - if (action === 'release') { - const releaseRequest = { expectedVersion: detail.value.version, billOfLadingNo: form.data.billOfLadingNo || '', vesselName: form.data.vesselName || '', voyageNo: form.data.voyageNo || '', estimatedSailingAt: form.data.estimatedSailingAt || '', estimatedArrivalAt: form.data.estimatedArrivalAt || '', reason } - applyDetail(await releaseOceanBooking(id.value, releaseRequest)) - } else applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason })) + applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason })) ElMessage.success(`${actionLabels[action]}成功`) } catch (cause) { if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause) @@ -278,14 +309,14 @@ onMounted(() => { void load(); void loadOptions() })

基本信息

- + - - + +
@@ -348,6 +379,18 @@ onMounted(() => { void load(); void loadOptions() }) + + + + + + + + + + + + diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts index dfe31c21..7d9f3ed6 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts @@ -41,7 +41,7 @@ describe('OceanBookingsView customer filter contract', () => { it('loads active customers, displays names only and submits customerId', () => { expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") expect(source).toContain("customer.status === 'ACTIVE'") - expect(source).toContain("customer.legalName?.trim() || customer.tenantName") + expect(source).toContain("label: customer.tenantName") expect(source).toContain("customerId: String(query.customerId || '') || undefined") expect(source).not.toContain('customer.tenantCode') }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.vue b/src/modules/ocean-bookings/pages/OceanBookingsView.vue index f4db1245..816d9a96 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingsView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.vue @@ -29,7 +29,7 @@ const customersLoading = ref(false) const query = reactive({ customerId: '', keyword: '', status: '' }) const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE')) const fields = computed(() => [ - { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.legalName?.trim() || customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, + { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号', width: 220 }, { key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions }, ]) diff --git a/src/modules/outbound-dispatch/pages/OutboundDispatchesView.test.ts b/src/modules/outbound-dispatch/pages/OutboundDispatchesView.test.ts index d27861ca..3cfb951d 100644 --- a/src/modules/outbound-dispatch/pages/OutboundDispatchesView.test.ts +++ b/src/modules/outbound-dispatch/pages/OutboundDispatchesView.test.ts @@ -74,9 +74,10 @@ describe('OutboundDispatchesView', () => { it('loads only active customer names for the filter', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8') - expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") - expect(source).toContain("records.filter((customer) => customer.status === 'ACTIVE')") - expect(source).toContain("item.legalName?.trim() || item.tenantName") + expect(source).toContain("listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })") + expect(source).toContain("result.records.filter((customer) => customer.status === 'ACTIVE')") + expect(source).toContain('while (currentPage <= pages)') + expect(source).toContain("item.tenantName") expect(source).not.toContain('item.tenantCode') }) diff --git a/src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue b/src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue index 6da7d72d..0e53cc09 100644 --- a/src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue +++ b/src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue @@ -39,7 +39,7 @@ let detailRequestId = 0 let disposed = false const fields = computed(() => [ - { key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.legalName?.trim() || item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } }, + { key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } }, { key: 'orderId', type: 'select', label: '订单 / 入库单', width: 280, placeholder: filters.value.customerId ? '请选择该客户的订单' : '请选择订单或入库单', options: orders.value.map((item) => ({ label: `${item.orderNo || item.orderId} / ${item.inboundCode || item.inboundId || '未入库'}`, value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } }, { key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } }, ]) @@ -115,12 +115,12 @@ async function loadOptions(customerId = filters.value.customerId) { optionsLoading.value = true optionsError.value = '' const results = await Promise.allSettled([ - listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }), + loadActiveCustomers(), listPlatformUnifiedOrders({ customerId: customerId || undefined, page: 1, size: 200 }), listContainers({ page: 1, size: 200 }), ]) if (disposed || requestId !== optionRequestId) return - if (results[0].status === 'fulfilled') customers.value = results[0].value.records.filter((customer) => customer.status === 'ACTIVE') + if (results[0].status === 'fulfilled') customers.value = results[0].value if (results[1].status === 'fulfilled') orders.value = results[1].value.records if (results[2].status === 'fulfilled') containers.value = results[2].value.records const failed = ['客户', '订单', '柜次'].filter((_, index) => results[index]?.status === 'rejected') @@ -128,6 +128,19 @@ async function loadOptions(customerId = filters.value.customerId) { optionsLoading.value = false } +async function loadActiveCustomers() { + const records: PlatformTenantResource[] = [] + let currentPage = 1 + let pages = 1 + do { + const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }) + records.push(...result.records.filter((customer) => customer.status === 'ACTIVE')) + pages = Math.max(1, result.pages) + currentPage += 1 + } while (currentPage <= pages) + return records +} + watch(() => filters.value.customerId, (customerId, previous) => { if (customerId === previous) return filters.value = { ...filters.value, orderId: '' } diff --git a/src/modules/port-schedules/pages/PortSchedulesView.test.ts b/src/modules/port-schedules/pages/PortSchedulesView.test.ts index 39ec5e99..2cef570b 100644 --- a/src/modules/port-schedules/pages/PortSchedulesView.test.ts +++ b/src/modules/port-schedules/pages/PortSchedulesView.test.ts @@ -26,10 +26,11 @@ describe('PortSchedulesView', () => { it('selects real customers and customer-scoped ocean bookings', () => { expect(source).toContain("from '@/modules/iam'") expect(source).toContain("from '@/modules/ocean-bookings'") - expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") + expect(source).toContain("listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })") + expect(source).toContain('while (currentPage <= pages)') expect(source).toContain('listOceanBookings({ page: 1, size: 200, customerId })') expect(source).toContain("customerId: String(query.customerId || '') || undefined") - expect(source).toContain('customer.legalName?.trim() || customer.tenantName') + expect(source).toContain('function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }') expect(source).not.toContain('customer.tenantCode') expect(source).toContain("form.oceanBookingId = ''") expect(source).not.toContain('label="客户 ID"') diff --git a/src/modules/port-schedules/pages/PortSchedulesView.vue b/src/modules/port-schedules/pages/PortSchedulesView.vue index 9b56750c..b38976a8 100644 --- a/src/modules/port-schedules/pages/PortSchedulesView.vue +++ b/src/modules/port-schedules/pages/PortSchedulesView.vue @@ -41,9 +41,9 @@ const selectableCustomers = computed(() => customers.value.filter((customer) => const canCreate = computed(() => access.can('platform:firstleg:port-schedule:create')) const dialogTitle = computed(() => dialogMode.value === 'create' ? '新增船期' : dialogMode.value === 'edit' ? '编辑船期' : '查看船期') const dialogReadonly = computed(() => dialogMode.value === 'view') -function customerLabel(customer: PlatformTenantResource) { return customer.legalName?.trim() || customer.tenantName } +function customerLabel(customer: PlatformTenantResource) { return customer.tenantName } function bookingLabel(booking: OceanBookingResource) { return [booking.bookingNo || '待生成订舱号', [booking.vesselName, booking.voyageNo].filter(Boolean).join(' / '), [booking.departurePortCode, booking.destinationPortCode].filter(Boolean).join(' → ')].filter(Boolean).join(' · ') } -async function loadCustomers() { const serial = ++customerRequestSerial; customersLoading.value = true; customersError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); if (serial === customerRequestSerial) customers.value = result.records } catch (cause) { if (serial === customerRequestSerial) customersError.value = getApiErrorDetails(cause, '客户候选加载失败') } finally { if (serial === customerRequestSerial) customersLoading.value = false } } +async function loadCustomers() { const serial = ++customerRequestSerial; customersLoading.value = true; customersError.value = null; try { const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1; do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages); if (serial === customerRequestSerial) customers.value = records } catch (cause) { if (serial === customerRequestSerial) customersError.value = getApiErrorDetails(cause, '客户候选加载失败') } finally { if (serial === customerRequestSerial) customersLoading.value = false } } async function loadBookings(customerId = String(form.customerId || '')) { const serial = ++bookingRequestSerial; bookings.value = []; bookingsError.value = null; if (!customerId) { bookingsLoading.value = false; return } bookingsLoading.value = true; try { const result = await listOceanBookings({ page: 1, size: 200, customerId }); if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookings.value = result.records } catch (cause) { if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookingsError.value = getApiErrorDetails(cause, '订舱候选加载失败') } finally { if (serial === bookingRequestSerial) bookingsLoading.value = false } } function handleCustomerChange(value: string) { form.customerId = String(value || ''); form.oceanBookingId = ''; void formRef.value?.clearValidate('oceanBookingId'); void loadBookings(form.customerId) } async function load() { loading.value = true; error.value = null; try { const result = await listPortSchedules({ page: page.value, size: pageSize.value, customerId: String(query.customerId || '') || undefined, keyword: String(query.keyword || '') || undefined, expired: query.expired === '' ? undefined : query.expired === 'true' }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } } diff --git a/src/modules/suppliers/pages/SupplierDetailView.vue b/src/modules/suppliers/pages/SupplierDetailView.vue index d5de8ae8..0bcf6b3a 100644 --- a/src/modules/suppliers/pages/SupplierDetailView.vue +++ b/src/modules/suppliers/pages/SupplierDetailView.vue @@ -1,5 +1,5 @@