优化优化
This commit is contained in:
@@ -211,6 +211,8 @@ export interface UnifiedOrderDetailResource {
|
||||
|
||||
export interface UnifiedOrderFeeDetailResource {
|
||||
id: number | string
|
||||
sourceFeeId?: number | string | null
|
||||
sourceFeeVersion?: number | null
|
||||
inboundId: number | string
|
||||
inboundCode: string | null
|
||||
reviewStatus: string | null
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
listPlatformUnifiedOrderFeeDetails,
|
||||
listPlatformUnifiedOrders,
|
||||
type UnifiedOrderFeeDetailResource,
|
||||
type UnifiedOrderResource,
|
||||
} from './api/unifiedOrders'
|
||||
@@ -0,0 +1,118 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import UnifiedOrdersView from './UnifiedOrdersView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
|
||||
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
|
||||
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
|
||||
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
addPlatformUnifiedOrderNote: vi.fn(),
|
||||
approvePlatformUnifiedOrder: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(),
|
||||
listPlatformUnifiedOrderFeeDetails: vi.fn(),
|
||||
listPlatformUnifiedOrders: vi.fn(),
|
||||
unifiedOrderStatusOptions: [
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||||
{ value: 'WAIT_MEASURE', label: '待测量数据' },
|
||||
{ value: 'INBOUNDED', label: '已入库' },
|
||||
{ value: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ value: 'LOADING', label: '装柜中' },
|
||||
{ value: 'LOADED', label: '装柜完成' },
|
||||
{ value: 'DELIVERED', label: '派送完成' },
|
||||
],
|
||||
}))
|
||||
|
||||
function emptyPage() {
|
||||
return { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return shallowMount(UnifiedOrdersView, {
|
||||
global: {
|
||||
directives: { loading: () => undefined },
|
||||
stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
|
||||
DataTable: { name: 'DataTable', props: ['page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div data-testid="data-table" />' },
|
||||
'el-button': { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('UnifiedOrdersView list request coordination', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
|
||||
})
|
||||
|
||||
it('loads once initially and once for query, reset, status tab and pagination', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
|
||||
|
||||
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
|
||||
|
||||
toolbar.vm.$emit('reset')
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
|
||||
|
||||
await wrapper.findAll('.status-tab')[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4)
|
||||
|
||||
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('does not apply draft filters to pagination until query is submitted', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
|
||||
const draft = { ...toolbar.props('modelValue'), inboundCode: ' DRAFT-CODE ' }
|
||||
toolbar.vm.$emit('update:modelValue', draft)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, inboundCode: undefined }))
|
||||
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, inboundCode: 'DRAFT-CODE' }))
|
||||
})
|
||||
|
||||
it('resets a later page and changes page size with one request per interaction', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const table = wrapper.getComponent({ name: 'DataTable' })
|
||||
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
|
||||
|
||||
table.vm.$emit('update:page', 3)
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
|
||||
|
||||
toolbar.vm.$emit('reset')
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
|
||||
|
||||
table.vm.$emit('update:pageSize', 50)
|
||||
table.vm.$emit('update:page', 1)
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4)
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 50 }))
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
it('renders accessible current-status tabs with keyboard navigation', () => {
|
||||
expect(source).toContain('role="tablist"')
|
||||
expect(source).toContain('role="tab"')
|
||||
expect(source).toContain(':aria-selected="filters.mainStatus === tab.value"')
|
||||
expect(source).toContain(':aria-selected="selectedStatus === tab.value"')
|
||||
expect(source).toContain("event.key === 'ArrowRight'")
|
||||
expect(source).toContain("event.key === 'ArrowLeft'")
|
||||
expect(source).toContain("event.key === 'Home'")
|
||||
@@ -38,8 +38,8 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('listAllPlatformWarehouses')
|
||||
expect(source).toContain("{ status: 'ACTIVE' }")
|
||||
expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'")
|
||||
expect(source).toContain("loaded: optionalBoolean(filters.value.loaded)")
|
||||
expect(source).toContain("fullContainer: optionalBoolean(filters.value.fullContainer)")
|
||||
expect(source).toContain("loaded: optionalBoolean(activeFilters.loaded)")
|
||||
expect(source).toContain("fullContainer: optionalBoolean(activeFilters.fullContainer)")
|
||||
expect(source).toContain("{ value: 'UNRECORDED', label: '未录入' }")
|
||||
expect(source).not.toContain("{ value: 'DRAFT', label: '草稿' }")
|
||||
expect(source).not.toContain("{ value: 'CANCELLED', label: '已取消' }")
|
||||
@@ -59,7 +59,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
})
|
||||
|
||||
it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => {
|
||||
expect(source).toContain('filters.value.mainStatus || undefined')
|
||||
expect(source).toContain('mainStatus: selectedStatus.value || undefined')
|
||||
expect(source).toContain('<template #actions>')
|
||||
expect(source).toContain(':aria-expanded="showAdvancedFilters"')
|
||||
expect(source).not.toContain('class="filter-mode-actions"')
|
||||
@@ -102,6 +102,14 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length"')
|
||||
})
|
||||
|
||||
it('keeps editable query values separate from the applied list request', () => {
|
||||
expect(source).toContain('const appliedFilters = ref<FilterModel>(emptyFilters())')
|
||||
expect(source).toContain('const activeFilters = appliedFilters.value')
|
||||
expect(source).toContain('appliedFilters.value = cloneFilters(filters.value)')
|
||||
expect(source).toContain('appliedFilters.value = emptyFilters()')
|
||||
expect(source).not.toContain('filters.value.mainStatus')
|
||||
})
|
||||
|
||||
it('keeps only confirmed business actions as explicit boundaries', () => {
|
||||
expect(source).toContain('nextActionBoundary')
|
||||
expect(source).not.toContain('class="action-boundary"')
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
tenantId: string
|
||||
mainStatus: UnifiedOrderMainStatus | ''
|
||||
receivingWarehouseId: string
|
||||
destinationWarehouseId: string
|
||||
inboundCode: string
|
||||
@@ -96,6 +95,8 @@ let detailLoadingKey = ''
|
||||
let detailRequestSerial = 0
|
||||
const showAdvancedFilters = ref(false)
|
||||
const filters = ref<FilterModel>(emptyFilters())
|
||||
const appliedFilters = ref<FilterModel>(emptyFilters())
|
||||
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
|
||||
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
|
||||
@@ -240,13 +241,17 @@ const columns: DataTableColumn[] = [
|
||||
|
||||
function emptyFilters(): FilterModel {
|
||||
return {
|
||||
tenantId: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '',
|
||||
tenantId: '', receivingWarehouseId: '', destinationWarehouseId: '',
|
||||
inboundCode: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
|
||||
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
|
||||
fbaCode: '', createdRange: [],
|
||||
}
|
||||
}
|
||||
|
||||
function cloneFilters(source: FilterModel): FilterModel {
|
||||
return { ...source, createdRange: [...source.createdRange] }
|
||||
}
|
||||
|
||||
function trimmed(value: string) {
|
||||
return value.trim() || undefined
|
||||
}
|
||||
@@ -256,8 +261,10 @@ function optionalBoolean(value: string) {
|
||||
}
|
||||
|
||||
function selectStatus(status: UnifiedOrderMainStatus | '') {
|
||||
filters.value = { ...filters.value, mainStatus: status }
|
||||
search()
|
||||
if (selectedStatus.value === status) return
|
||||
selectedStatus.value = status
|
||||
if (page.value === 1) void loadOrders()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function handleTabKeydown(event: KeyboardEvent, index: number) {
|
||||
@@ -625,23 +632,24 @@ async function loadOrders() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const range = filters.value.createdRange
|
||||
const activeFilters = appliedFilters.value
|
||||
const range = activeFilters.createdRange
|
||||
const result = await listPlatformUnifiedOrders({
|
||||
tenantId: filters.value.tenantId || undefined,
|
||||
mainStatus: filters.value.mainStatus || undefined,
|
||||
receivingWarehouseId: filters.value.receivingWarehouseId || undefined,
|
||||
destinationWarehouseId: filters.value.destinationWarehouseId || undefined,
|
||||
inboundCode: trimmed(filters.value.inboundCode),
|
||||
expectedArrivalDate: filters.value.expectedArrivalDate || undefined,
|
||||
feeReviewStatus: filters.value.feeReviewStatus || undefined,
|
||||
channelName: trimmed(filters.value.channelName),
|
||||
containerNo: trimmed(filters.value.containerNo),
|
||||
cabinetNo: trimmed(filters.value.cabinetNo),
|
||||
sailingDate: filters.value.sailingDate || undefined,
|
||||
loaded: optionalBoolean(filters.value.loaded),
|
||||
fullContainer: optionalBoolean(filters.value.fullContainer),
|
||||
measurementReviewStatus: filters.value.measurementReviewStatus || undefined,
|
||||
fbaCode: trimmed(filters.value.fbaCode),
|
||||
tenantId: activeFilters.tenantId || undefined,
|
||||
mainStatus: selectedStatus.value || undefined,
|
||||
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
|
||||
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
|
||||
inboundCode: trimmed(activeFilters.inboundCode),
|
||||
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
|
||||
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
|
||||
channelName: trimmed(activeFilters.channelName),
|
||||
containerNo: trimmed(activeFilters.containerNo),
|
||||
cabinetNo: trimmed(activeFilters.cabinetNo),
|
||||
sailingDate: activeFilters.sailingDate || undefined,
|
||||
loaded: optionalBoolean(activeFilters.loaded),
|
||||
fullContainer: optionalBoolean(activeFilters.fullContainer),
|
||||
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
|
||||
fbaCode: trimmed(activeFilters.fbaCode),
|
||||
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
|
||||
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
|
||||
page: page.value,
|
||||
@@ -660,6 +668,7 @@ async function loadOrders() {
|
||||
}
|
||||
|
||||
function search() {
|
||||
appliedFilters.value = cloneFilters(filters.value)
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
@@ -669,6 +678,7 @@ function search() {
|
||||
|
||||
function reset() {
|
||||
filters.value = emptyFilters()
|
||||
appliedFilters.value = emptyFilters()
|
||||
if (page.value === 1) void loadOrders()
|
||||
else page.value = 1
|
||||
}
|
||||
@@ -1026,10 +1036,10 @@ onMounted(() => {
|
||||
:key="tab.value || 'ALL'"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="filters.mainStatus === tab.value"
|
||||
:aria-selected="selectedStatus === tab.value"
|
||||
aria-controls="unified-orders-results"
|
||||
:tabindex="filters.mainStatus === tab.value ? 0 : -1"
|
||||
:class="['status-tab', { 'is-active': filters.mainStatus === tab.value }]"
|
||||
:tabindex="selectedStatus === tab.value ? 0 : -1"
|
||||
:class="['status-tab', { 'is-active': selectedStatus === tab.value }]"
|
||||
@click="selectStatus(tab.value)"
|
||||
@keydown="handleTabKeydown($event, index)"
|
||||
>{{ tab.label }}</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getOutboundDispatch,
|
||||
getPreload,
|
||||
smartSplit,
|
||||
voidEvidence,
|
||||
} from '../api'
|
||||
|
||||
vi.mock('../api', async (importOriginal) => {
|
||||
@@ -19,6 +20,7 @@ vi.mock('../api', async (importOriginal) => {
|
||||
getOutboundDispatch: vi.fn(),
|
||||
getPreload: vi.fn(),
|
||||
smartSplit: vi.fn(),
|
||||
voidEvidence: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -50,6 +52,7 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
|
||||
vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
|
||||
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
|
||||
vi.mocked(smartSplit).mockReset()
|
||||
vi.mocked(voidEvidence).mockReset()
|
||||
})
|
||||
|
||||
it('renders the formal preload table and keeps the operation column first', async () => {
|
||||
@@ -116,4 +119,41 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
|
||||
expect(wrapper.text()).not.toContain('完成装柜')
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses a validated dialog when voiding evidence', async () => {
|
||||
vi.mocked(getEvidence).mockResolvedValue({
|
||||
inboundId: '2086085512165634051',
|
||||
evidences: [
|
||||
{ id: '1', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '11', fileName: 'wrong.jpg', sealNo: null, remark: null, version: 2, status: 'ACTIVE', createdAt: null, updatedAt: null },
|
||||
],
|
||||
})
|
||||
vi.mocked(voidEvidence).mockResolvedValue({} as never)
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '照片上传错误' } as never)
|
||||
const wrapper = mountPanel(['platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:edit'], 'LOADING')
|
||||
await flushPromises()
|
||||
|
||||
const voidButton = wrapper.findAll('button').find((button) => button.text() === '作废')
|
||||
expect(voidButton).toBeTruthy()
|
||||
await voidButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(prompt).toHaveBeenCalledWith(
|
||||
'作废后照片仍会保留在审计记录中。',
|
||||
'作废证据照片',
|
||||
expect.objectContaining({
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '请输入作废原因',
|
||||
type: 'warning',
|
||||
}),
|
||||
)
|
||||
const options = prompt.mock.calls[0]?.[2]
|
||||
expect(options?.inputValidator?.(' ')).toBe('作废原因不能为空')
|
||||
expect(voidEvidence).toHaveBeenCalledWith(
|
||||
'2086085512165634052',
|
||||
'2086085512165634051',
|
||||
'1',
|
||||
{ expectedVersion: 2, reason: '照片上传错误' },
|
||||
)
|
||||
prompt.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -345,8 +346,21 @@ async function uploadAndSaveEvidence(type: EvidenceType, event: Event) {
|
||||
|
||||
async function markEvidenceVoided(item: Evidence) {
|
||||
if (!canEvidence.value || !item.id || String(item.id).startsWith('draft-')) return
|
||||
const reason = window.prompt('请输入照片作废原因')?.trim()
|
||||
if (!reason) return
|
||||
let reason = ''
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('作废后照片仍会保留在审计记录中。', '作废证据照片', {
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '请输入作废原因',
|
||||
inputValidator: (value) => Boolean(value?.trim()) || '作废原因不能为空',
|
||||
confirmButtonText: '确认作废',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
reason = result.value.trim()
|
||||
} catch (cause) {
|
||||
if (cause === 'cancel' || cause === 'close') return
|
||||
throw cause
|
||||
}
|
||||
const result = await run(`evidence-void-${item.id}`, () => voidEvidence(props.tenantId, props.inboundId, item.id, {
|
||||
expectedVersion: item.version,
|
||||
reason,
|
||||
|
||||
Reference in New Issue
Block a user