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/components/InboundWorkflowWorkspace.vue b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue index cb6c61ab..e68784eb 100644 --- a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue +++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue @@ -241,7 +241,7 @@ onBeforeUnmount(() => { 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 @@ diff --git a/src/modules/suppliers/pages/SupplierFormView.vue b/src/modules/suppliers/pages/SupplierFormView.vue index e02a4bec..4a57da4a 100644 --- a/src/modules/suppliers/pages/SupplierFormView.vue +++ b/src/modules/suppliers/pages/SupplierFormView.vue @@ -1,5 +1,5 @@ @@ -109,5 +120,7 @@ onMounted(load) .supplier-form-panel { padding: 22px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }.span-2 { grid-column: 1 / -1; } .form-grid :deep(.el-select) { width: 100%; }.file-placeholder { display: flex; width: 100%; min-height: 96px; align-items: center; justify-content: center; flex-direction: column; gap: 6px; padding: 16px; border: 1px dashed #d9d9d9; border-radius: 6px; background: #fafafa; color: var(--cc-muted); text-align: center; }.file-placeholder small { max-width: 420px; line-height: 1.5; } .form-actions { display: flex; justify-content: center; gap: 14px; padding: 18px 0 0; border-top: 1px solid var(--cc-border); }.form-actions :deep(.el-button) { min-width: 112px; margin-left: 0; } +@media (min-width: 1600px) { .form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } +@media (min-width: 1280px) and (max-width: 1599px) { .form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 720px) { .supplier-form-panel { padding: 16px; } .form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: auto; }.form-page-header { align-items: stretch; flex-direction: column; }.form-actions { flex-direction: column; }.form-actions :deep(.el-button) { width: 100%; min-height: 44px; } } diff --git a/src/modules/suppliers/pages/SupplierRouteReuse.behavior.test.ts b/src/modules/suppliers/pages/SupplierRouteReuse.behavior.test.ts new file mode 100644 index 00000000..5827b32b --- /dev/null +++ b/src/modules/suppliers/pages/SupplierRouteReuse.behavior.test.ts @@ -0,0 +1,87 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { reactive, nextTick } from 'vue' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import ElementPlus from 'element-plus' +import type { Component } from 'vue' +import type { Supplier } from '@/modules/suppliers/types' + +const mocks = vi.hoisted(() => ({ + route: null as null | { params: { id: string } }, + push: vi.fn(), getSupplier: vi.fn(), updateSupplier: vi.fn(), + can: vi.fn((permission: string) => permission === 'platform:supplier:list' || permission === 'platform:supplier:edit' || permission === 'platform:supplier:create'), +})) +const routeState = reactive({ params: { id: 'A' } }) +mocks.route = routeState +let SupplierFormView: Component +let SupplierDetailView: Component + +vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) })) +vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) +vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({ getSupplier: mocks.getSupplier, updateSupplier: mocks.updateSupplier, createSupplier: vi.fn() })) + +function supplier(id: string, name: string): Supplier { + return { id, tenantId: '0', name, category: 'BOOKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((next) => { resolve = next }) + return { promise, resolve } +} + +const stubs = { + AdminPage: { template: '' }, + DetailPageLayout: { template: '' }, + FormSection: { template: '' }, + ApiErrorMeta: { template: '' }, + LoadErrorState: { template: '' }, + SupplierLicenseDialog: { template: '' }, +} + +describe('supplier route reuse', () => { + beforeAll(async () => { + SupplierFormView = (await import('@/modules/suppliers/pages/SupplierFormView.vue')).default + SupplierDetailView = (await import('@/modules/suppliers/pages/SupplierDetailView.vue')).default + }) + beforeEach(() => { + routeState.params.id = 'A' + mocks.push.mockReset(); mocks.getSupplier.mockReset(); mocks.updateSupplier.mockReset().mockResolvedValue(null) + mocks.can.mockClear() + }) + + it('reloads form A to B, ignores late A, and submits B', async () => { + const requestA = deferred(); const requestB = deferred() + mocks.getSupplier.mockImplementation((id: string) => id === 'A' ? requestA.promise : requestB.promise) + const wrapper = mount(SupplierFormView, { global: { plugins: [ElementPlus], stubs } }) + expect(mocks.getSupplier).toHaveBeenCalledWith('A') + + routeState.params.id = 'B' + await nextTick() + expect(mocks.getSupplier).toHaveBeenLastCalledWith('B') + requestB.resolve(supplier('B', '供应商B')); await flushPromises() + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('供应商B') + requestA.resolve(supplier('A', '供应商A')); await flushPromises() + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('供应商B') + expect((wrapper.find('input').element as HTMLInputElement).value).not.toBe('供应商A') + + const saveButton = wrapper.findAll('button').find((button) => button.text().includes('保存')) + expect(saveButton).toBeDefined() + await saveButton!.trigger('click') + await flushPromises() + expect(mocks.updateSupplier).toHaveBeenCalledWith('B', expect.objectContaining({ name: '供应商B' })) + expect(mocks.push).toHaveBeenCalledWith({ name: 'supplier-list' }) + }) + + it('reloads detail A to B when the route is reused', async () => { + const requestA = deferred(); const requestB = deferred() + mocks.getSupplier.mockImplementation((id: string) => id === 'A' ? requestA.promise : requestB.promise) + const wrapper = mount(SupplierDetailView, { global: { plugins: [ElementPlus], stubs } }) + routeState.params.id = 'B'; await nextTick() + requestB.resolve(supplier('B', '供应商B')); await flushPromises() + expect(wrapper.text()).toContain('供应商B') + requestA.resolve(supplier('A', '供应商A')); await flushPromises() + expect(wrapper.text()).toContain('供应商B') + expect(wrapper.text()).not.toContain('供应商A') + }) +}) diff --git a/src/modules/suppliers/pages/SuppliersView.test.ts b/src/modules/suppliers/pages/SuppliersView.test.ts index c1508f86..5b10c09b 100644 --- a/src/modules/suppliers/pages/SuppliersView.test.ts +++ b/src/modules/suppliers/pages/SuppliersView.test.ts @@ -14,6 +14,14 @@ describe('supplier formal pages', () => { expect(source).toContain("access.can('platform:supplier:edit')") expect(source).toContain("access.can('platform:supplier:status')") expect(source).toContain("access.can('platform:supplier:list')") + expect(source).toContain(' { + expect(source).toContain('class="supplier-list-top"') + expect(source).toContain('class="supplier-query-panel"') + expect(source).toContain('class="supplier-query-toolbar"') + expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px') }) it('uses independent routes for list, detail, create and edit', () => { @@ -34,4 +42,11 @@ describe('supplier formal pages', () => { expect(formSource).toContain(' { + expect(detailSource).toContain("watch(supplierId, () => { void load() }, { immediate: true })") + expect(detailSource).toContain('const requestedId = supplierId.value') + expect(detailSource).toContain('if (sequence !== loadSequence || requestedId !== supplierId.value) return') + expect(detailSource).toContain("params: { id: supplier.id }") + }) }) diff --git a/src/modules/suppliers/pages/SuppliersView.vue b/src/modules/suppliers/pages/SuppliersView.vue index 933878d5..b9e649e2 100644 --- a/src/modules/suppliers/pages/SuppliersView.vue +++ b/src/modules/suppliers/pages/SuppliersView.vue @@ -115,15 +115,20 @@ onMounted(load) - - - - {{ item.label }}{{ item.value }}家 - - - - + + + + {{ item.label }}{{ item.value }}家 + + + + + {{ categoryLabels[item.category] }} + + + + @@ -132,24 +137,21 @@ onMounted(load) - - {{ categoryLabels[item.category] }} - 新增供应商 - 详情 - 编辑 - {{ row.status === 'ACTIVE' ? '停用' : '启用' }} + 详情 + 编辑 + {{ row.status === 'ACTIVE' ? '停用' : '启用' }} {{ row.name }} {{ categoryLabels[row.category as SupplierCategory] }} {{ row.scope ? scopeLabels[row.scope as SupplierScope] : '—' }} - 查看 + 查看 {{ settlementLabels[supplierRow(row).settlementPeriod] }} @@ -160,13 +162,8 @@ onMounted(load) diff --git a/src/modules/trucking/api/platformTrucking.test.ts b/src/modules/trucking/api/platformTrucking.test.ts index 35c7fe2d..f6a176ea 100644 --- a/src/modules/trucking/api/platformTrucking.test.ts +++ b/src/modules/trucking/api/platformTrucking.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment } from './platformTrucking' +import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from './platformTrucking' vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data })) @@ -40,4 +40,14 @@ describe('truck appointment API', () => { expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/77/attachments', { fileId: '9007199254740993', fileName: '派车单.pdf' }) expect(http.delete).toHaveBeenCalledWith('/platform/v1/truck-appointments/77/attachments/1') }) + + it('uploads an attachment through the frozen multipart endpoint', async () => { + vi.mocked(http.post).mockResolvedValue({ data: { id: '1', fileId: '2', fileName: '派车单.pdf' } }) + const file = new File(['dispatch'], '派车单.pdf', { type: 'application/pdf' }) + await uploadTruckAppointmentAttachment('77', file, 'DISPATCH_ORDER') + expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/77/attachments/upload', expect.any(FormData), { headers: { 'Content-Type': 'multipart/form-data' } }) + const form = vi.mocked(http.post).mock.calls[0]?.[1] as FormData + expect(form.get('file')).toBe(file) + expect(form.get('attachmentType')).toBe('DISPATCH_ORDER') + }) }) diff --git a/src/modules/trucking/api/platformTrucking.ts b/src/modules/trucking/api/platformTrucking.ts index 54f9a1cc..2c6815f6 100644 --- a/src/modules/trucking/api/platformTrucking.ts +++ b/src/modules/trucking/api/platformTrucking.ts @@ -71,6 +71,14 @@ export async function referenceTruckAppointmentAttachment(id: string | number, p return unwrapApiResponse(response) } +export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: string | null) { + const form = new FormData() + form.append('file', file) + if (attachmentType) form.append('attachmentType', attachmentType) + const response = await http.post, ApiEnvelope>(`${basePath}/${id}/attachments/upload`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) + return unwrapApiResponse(response) +} + export async function deleteTruckAppointmentAttachment(id: string | number, attachmentId: string | number) { await http.delete, ApiEnvelope>(`${basePath}/${id}/attachments/${attachmentId}`) } diff --git a/src/modules/trucking/components/TruckAppointmentForm.test.ts b/src/modules/trucking/components/TruckAppointmentForm.test.ts index 18332926..1ba7ed5b 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.test.ts @@ -14,7 +14,7 @@ describe('TruckAppointmentForm interaction contract', () => { expect(source).not.toContain('label="承运商 ID"') expect(source).not.toContain('label="客户关联 ID"') expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") - expect(source).toContain('customer.legalName?.trim() || customer.tenantName') + expect(source).toContain('function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }') expect(source).not.toContain('customer.tenantCode') }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index ac5f6d6c..f25b2765 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -51,7 +51,7 @@ const selectableWarehouses = computed(() => warehouses.value.filter((item) => ( const selectableCarriers = computed(() => carriers.value.filter((item) => ( item.status === 'ACTIVE' || String(item.id) === String(draft.carrierId) ))) -function customerLabel(customer: PlatformTenantResource) { return customer.legalName?.trim() || customer.tenantName } +function customerLabel(customer: PlatformTenantResource) { return customer.tenantName } function selectCustomer(customerId: string) { const customer = customers.value.find((item) => String(item.id) === customerId); draft.customerId = customerId || null; draft.customerName = customer ? customerLabel(customer) : null } function selectCarrier(carrierId: string) { draft.carrierId = carrierId || null; draft.carrierName = carriers.value.find((item) => String(item.id) === carrierId)?.partnerName || null } function selectContainer(containerId: string) { const item = containers.value.find((entry) => entry.id === containerId); draft.containerBookingId = containerId || null; if (item) { draft.loadingPlanId = item.planId; draft.warehouseId = item.warehouseId } } diff --git a/src/modules/trucking/components/TruckAttachmentDialog.test.ts b/src/modules/trucking/components/TruckAttachmentDialog.test.ts index 29f54582..51c9e8b6 100644 --- a/src/modules/trucking/components/TruckAttachmentDialog.test.ts +++ b/src/modules/trucking/components/TruckAttachmentDialog.test.ts @@ -12,10 +12,13 @@ describe('TruckAttachmentDialog interaction governance', () => { expect(source).not.toContain('item.objectKey') }) - it('hides the unavailable manual reference flow but keeps deletion', () => { + it('provides scoped upload and deletion without exposing manual file ids', () => { expect(source).not.toContain('referenceTruckAppointmentAttachment') expect(source).not.toContain('输入文件服务 ID') + expect(source).toContain('uploadTruckAppointmentAttachment') + expect(source).toContain('上传并关联') expect(source).toContain('deleteTruckAppointmentAttachment') + expect(source).toContain("confirmButtonText: '确认', cancelButtonText: '取消'") expect(source).toContain('删除') expect(source).toContain('size="small" type="danger" plain') expect(source).not.toContain(' text type="danger"') diff --git a/src/modules/trucking/components/TruckAttachmentDialog.vue b/src/modules/trucking/components/TruckAttachmentDialog.vue index d068b3cf..0751c3d9 100644 --- a/src/modules/trucking/components/TruckAttachmentDialog.vue +++ b/src/modules/trucking/components/TruckAttachmentDialog.vue @@ -1,18 +1,23 @@ + 选择附件上传并关联 {{ item.fileName || '未命名文件' }}{{ attachmentTypeLabel(item.attachmentType) }}{{ item.uploadedAt ? ` · ${item.uploadedAt}` : '' }} @@ -22,6 +27,7 @@ async function remove(item: TruckAttachmentReference) { if (!props.appointmentId diff --git a/src/modules/trucking/pages/TruckAppointmentsView.test.ts b/src/modules/trucking/pages/TruckAppointmentsView.test.ts index 325daa76..5bb9b2a6 100644 --- a/src/modules/trucking/pages/TruckAppointmentsView.test.ts +++ b/src/modules/trucking/pages/TruckAppointmentsView.test.ts @@ -125,7 +125,7 @@ describe('TruckAppointmentsView', () => { it('filters with an active name-only customer selector and stable 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(model.customerId || '')") expect(source).not.toContain('customer.tenantCode') }) diff --git a/src/modules/trucking/pages/TruckAppointmentsView.vue b/src/modules/trucking/pages/TruckAppointmentsView.vue index 6744d5c1..7d2a94f5 100644 --- a/src/modules/trucking/pages/TruckAppointmentsView.vue +++ b/src/modules/trucking/pages/TruckAppointmentsView.vue @@ -20,7 +20,7 @@ const access = useAccessPreviewStore() const customers = ref([]); const customersLoading = ref(false) const filters = ref({ customerId: '', keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value }) 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: '派车单号、车牌号、运输公司' }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) }, { key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', options: [ diff --git a/src/modules/warehouses/pages/WarehousesView.test.ts b/src/modules/warehouses/pages/WarehousesView.test.ts index e6c5757d..b377a11a 100644 --- a/src/modules/warehouses/pages/WarehousesView.test.ts +++ b/src/modules/warehouses/pages/WarehousesView.test.ts @@ -257,6 +257,25 @@ describe('WarehousesView', () => { }) }) + it('submits the current keyword once when Enter is pressed in the text filter', async () => { + const wrapper = await mountView() + const keywordInput = wrapper.find('input[placeholder="仓库名称、编码、城市或地址"]') + + await keywordInput.setValue('AKR1') + await keywordInput.trigger('keyup', { key: 'Enter' }) + await flushPromises() + + expect(listPlatformWarehouses).toHaveBeenCalledTimes(2) + expect(listPlatformWarehouses).toHaveBeenLastCalledWith({ + page: 1, + size: 20, + keyword: 'AKR1', + warehouseType: undefined, + status: undefined, + }) + expect(wrapper.findComponent(QueryToolbar).props('loading')).toBe(false) + }) + it('shows a recoverable API error without falling back to demo warehouses', async () => { vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('Gateway unavailable')) const wrapper = await mountView() diff --git a/src/modules/warehouses/pages/WarehousesView.vue b/src/modules/warehouses/pages/WarehousesView.vue index 9f6baa4b..6068d71f 100644 --- a/src/modules/warehouses/pages/WarehousesView.vue +++ b/src/modules/warehouses/pages/WarehousesView.vue @@ -672,7 +672,7 @@ onMounted(() => void loadWarehouses()) - + ', + emits: ['keyup'], + template: '', +}) +const ButtonStub = defineComponent({ + props: { disabled: Boolean, loading: Boolean }, + template: '', }) -const ButtonStub = defineComponent({ template: '' }) const PopoverStub = defineComponent({ template: '' }) const fields: QueryField[] = [ @@ -21,7 +25,7 @@ const fields: QueryField[] = [ { key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '创建日期' }, ] -function mountToolbar() { +function mountToolbar(props: Record = {}) { return shallowMount(QueryToolbar, { props: { modelValue: { keyword: '', status: '', createdRange: [] }, @@ -29,6 +33,7 @@ function mountToolbar() { ariaLabel: '订单筛选', fields, preferenceKey: 'orders-query', + ...props, }, global: { provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') }, @@ -70,4 +75,49 @@ describe('QueryToolbar layout preferences', () => { const source = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8') expect(source).toContain('.query-toolbar__tail { display: flex; align-items: flex-end; width: auto; flex: 0 0 auto; gap: 10px; }') }) + + it('submits the current filters once when Enter is released in a text input', async () => { + const wrapper = mountToolbar({ modelValue: { keyword: 'AKR1', status: 'ACTIVE', createdRange: [] } }) + const textInput = wrapper.findAll('input')[0] + + await textInput.trigger('keyup', { key: 'Enter' }) + + expect(wrapper.emitted('search')).toEqual([[ + { keyword: 'AKR1', status: 'ACTIVE', createdRange: [] }, + ]]) + }) + + it('does not submit Enter from select or date controls, composition, or repeated key events', async () => { + const wrapper = mountToolbar() + const controls = wrapper.findAll('input') + + await controls[1].trigger('keyup', { key: 'Enter' }) + await controls[2].trigger('keyup', { key: 'Enter' }) + await controls[0].trigger('keyup', { key: 'Enter', isComposing: true }) + await controls[0].trigger('keyup', { key: 'Enter', repeat: true }) + + expect(wrapper.emitted('search')).toBeUndefined() + }) + + it('resets all filters once and blocks search and reset while loading', async () => { + const wrapper = mountToolbar({ + modelValue: { keyword: 'AKR1', status: 'ACTIVE', createdRange: ['2026-08-01', '2026-08-14'] }, + }) + + ;(wrapper.vm as unknown as { reset: () => void }).reset() + expect(wrapper.emitted('reset')).toEqual([[ + { keyword: '', status: '', createdRange: [] }, + ]]) + expect(wrapper.emitted('search')).toEqual([[ + { keyword: '', status: '', createdRange: [] }, + ]]) + + await wrapper.setProps({ loading: true }) + ;(wrapper.vm as unknown as { search: () => void; reset: () => void }).search() + ;(wrapper.vm as unknown as { search: () => void; reset: () => void }).reset() + expect(wrapper.emitted('search')).toHaveLength(1) + expect(wrapper.emitted('reset')).toHaveLength(1) + expect(wrapper.findAllComponents(ButtonStub)[0].props('loading')).toBe(true) + expect(wrapper.findAllComponents(ButtonStub)[1].props('disabled')).toBe(true) + }) }) diff --git a/src/shared/components/QueryToolbar.vue b/src/shared/components/QueryToolbar.vue index 93fad226..91372723 100644 --- a/src/shared/components/QueryToolbar.vue +++ b/src/shared/components/QueryToolbar.vue @@ -40,10 +40,12 @@ const props = withDefaults(defineProps<{ searchText?: string resetText?: string searchOnReset?: boolean + loading?: boolean }>(), { searchText: '查询', resetText: '重置', searchOnReset: true, + loading: false, }) const model = defineModel({ required: true }) @@ -140,10 +142,12 @@ function resetValue(field: QueryField): QueryValue { } function search() { + if (props.loading) return emit('search', model.value) } function reset() { + if (props.loading) return const next = { ...model.value } for (const field of props.fields) next[field.key] = resetValue(field) model.value = next @@ -151,6 +155,11 @@ function reset() { if (props.searchOnReset) emit('search', next) } +function searchFromTextInput(event: KeyboardEvent) { + if (event.isComposing || event.repeat) return + search() +} + defineExpose({ search, reset }) @@ -175,6 +184,7 @@ defineExpose({ search, reset }) clearable v-bind="field.props" @update:model-value="updateField(field.key, $event)" + @keyup.enter.exact="searchFromTextInput" > @@ -203,8 +213,8 @@ defineExpose({ search, reset }) /> - {{ searchText }} - {{ resetText }} + {{ searchText }} + {{ resetText }} @@ -216,8 +226,8 @@ defineExpose({ search, reset }) - {{ searchText }} - {{ resetText }} + {{ searchText }} + {{ resetText }}
当前输入仍保留,请先核对最新版本后再提交。