From e3ecba7133f33ead45cd2e2434a4737b4b494c4a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 11:10:22 +0800 Subject: [PATCH] fix(admin): align loading history and pallet lookup --- .../inbounds/weight/api/weightOcr.test.ts | 10 ++-- src/modules/inbounds/weight/api/weightOcr.ts | 13 +++++- .../WeightOcrPanel.behavior.test.ts | 37 +++++++++++++-- .../weight/components/WeightOcrPanel.vue | 29 ++++++++++-- .../api/platformLoadingLists.test.ts | 4 +- .../loading-lists/api/platformLoadingLists.ts | 2 + .../components/LoadingListDetailDrawer.vue | 46 +++++++++++++++++-- .../components/LoadingListsView.test.ts | 15 ++++++ 8 files changed, 136 insertions(+), 20 deletions(-) diff --git a/src/modules/inbounds/weight/api/weightOcr.test.ts b/src/modules/inbounds/weight/api/weightOcr.test.ts index 1e71a319..52b5298e 100644 --- a/src/modules/inbounds/weight/api/weightOcr.test.ts +++ b/src/modules/inbounds/weight/api/weightOcr.test.ts @@ -93,26 +93,26 @@ describe('inbound weight OCR API', () => { data: { records, page: '1', size: '20', total: '2', pages: '1' }, }) - await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual({ + await expect(getWeightPalletOptions('10001', '20001', { page: 1, size: 50 })).resolves.toEqual({ records, page: 1, size: 20, total: 2, pages: 1, }) expect(http.get).toHaveBeenCalledWith( '/platform/v1/first-leg/inbounds/20001/weight-pallet-options', - { params: { tenantId: '10001' } }, + { params: { tenantId: '10001', page: 1, size: 50, keyword: undefined } }, ) }) - it('passes an abort signal without changing the frozen pallet endpoint parameters', async () => { + it('passes remote page, keyword and abort signal through the frozen pallet endpoint', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' }, }) const controller = new AbortController() - await getWeightPalletOptions('10001', '20001', controller.signal) + await getWeightPalletOptions('10001', '20001', { page: 2, size: 50, keyword: ' 木托盘 ', signal: controller.signal }) expect(http.get).toHaveBeenCalledWith( '/platform/v1/first-leg/inbounds/20001/weight-pallet-options', - { params: { tenantId: '10001' }, signal: controller.signal }, + { params: { tenantId: '10001', page: 2, size: 50, keyword: '木托盘' }, signal: controller.signal }, ) }) diff --git a/src/modules/inbounds/weight/api/weightOcr.ts b/src/modules/inbounds/weight/api/weightOcr.ts index 927a3c76..5fe7b276 100644 --- a/src/modules/inbounds/weight/api/weightOcr.ts +++ b/src/modules/inbounds/weight/api/weightOcr.ts @@ -74,10 +74,19 @@ export async function getWeightWorkspace(tenantId: string, inboundId: string) { return unwrapApiResponse(response) } -export async function getWeightPalletOptions(tenantId: string, inboundId: string, signal?: AbortSignal) { +export async function getWeightPalletOptions(tenantId: string, inboundId: string, query: { + page?: number + size?: number + keyword?: string + signal?: AbortSignal +} = {}) { + const keyword = query.keyword?.trim() const response = await http.get, ApiEnvelope>( `${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`, - { params: { tenantId }, ...(signal ? { signal } : {}) }, + { + params: { tenantId, page: query.page ?? 1, size: query.size ?? 50, keyword: keyword || undefined }, + ...(query.signal ? { signal: query.signal } : {}), + }, ) const page = unwrapApiResponse(response) return { diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts index 12c44f01..f9745c05 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts @@ -1,5 +1,6 @@ import ElementPlus from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus' +import { AxiosError } from 'axios' import { createPinia, setActivePinia } from 'pinia' import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -221,7 +222,7 @@ describe('WeightOcrPanel confirmation behavior', () => { await flushPromises() await openWeightDialog(wrapper) - const dialogText = document.body.textContent || '' + const dialogText = document.body.querySelector('.el-dialog')?.textContent || '' expect(dialogText).toContain('当前托盘皮重') expect(dialogText).toContain('12.000 KG') expect(dialogText).toContain('OCR 识别毛重') @@ -360,6 +361,24 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(wrapper.text()).not.toContain('可选托盘加载失败') }) + it('remote search keyword uses page 1 and size 50', async () => { + const wrapper = mountPanel() + await flushPromises() + vi.useFakeTimers() + try { + await wrapper.find('.pallet-picker .el-select').trigger('click') + await wrapper.find('.pallet-picker input').setValue(' 木托盘 ') + await vi.advanceTimersByTimeAsync(350) + await flushPromises() + + expect(getWeightPalletOptions).toHaveBeenLastCalledWith('10001', '20001', expect.objectContaining({ + page: 1, size: 50, keyword: '木托盘', signal: expect.any(AbortSignal), + })) + } finally { + vi.useRealTimers() + } + }) + it('shows pallet loading failures without hiding existing weight results', async () => { vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503)) const wrapper = mountPanel() @@ -369,6 +388,17 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(wrapper.findAll('.weight-card')).toHaveLength(1) }) + it('shows a network error as a pallet candidate load failure without hiding historical snapshots', async () => { + vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new AxiosError('Network Error', 'ERR_NETWORK')) + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.text()).toContain('可选托盘加载失败') + expect(wrapper.findAll('.weight-card')).toHaveLength(1) + expect(wrapper.text()).toContain('木托盘') + expect(wrapper.text()).toContain('12.000 KG') + }) + it('does not load weight or pallet data without measurement view permission', async () => { const wrapper = mountPanel([]) await flushPromises() @@ -378,13 +408,14 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(getWeightPalletOptions).not.toHaveBeenCalled() }) - it('keeps historical pallet snapshots visible without requesting candidates for a read-only operator', async () => { + it('keeps historical palletCode snapshots visible for a read-only operator', async () => { const wrapper = mountPanel(['platform:firstleg:inbound:measure:view']) await flushPromises() expect(getWeightWorkspace).toHaveBeenCalledOnce() expect(getWeightPalletOptions).not.toHaveBeenCalled() expect(wrapper.find('.pallet-picker .el-select').exists()).toBe(false) + expect(wrapper.text()).toContain('TP-001') expect(wrapper.text()).toContain('木托盘') expect(wrapper.text()).toContain('12.000 KG') }) @@ -424,7 +455,7 @@ describe('WeightOcrPanel confirmation behavior', () => { })) const wrapper = mountPanel() await flushPromises() - const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2] + const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]?.signal wrapper.unmount() resolveOptions?.(palletPage([{ diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.vue b/src/modules/inbounds/weight/components/WeightOcrPanel.vue index a8e1daf7..2518c26a 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.vue +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.vue @@ -45,6 +45,7 @@ const fileInput = ref() const workspace = ref(null) const palletOptions = ref([]) const selectedPalletId = ref('') +const palletKeyword = ref('') const palletLoading = ref(false) const palletError = ref(null) const loading = ref(false) @@ -61,6 +62,7 @@ const weightDrafts = ref option.palletId === selectedPalletId.value)) selectedPalletId.value = '' @@ -257,6 +265,14 @@ async function loadPalletOptions() { } } +function searchPalletOptions(keyword: string) { + void loadPalletOptions(keyword) +} + +function reloadPalletOptions() { + void loadPalletOptions() +} + function cancelPalletOptionsLoad() { ++palletLoadSequence palletAbortController?.abort() @@ -417,6 +433,7 @@ watch( workspace.value = null palletOptions.value = [] selectedPalletId.value = '' + palletKeyword.value = '' palletError.value = null weightDrafts.value = {} loadError.value = null @@ -433,6 +450,7 @@ watch( cancelPalletOptionsLoad() palletOptions.value = [] selectedPalletId.value = '' + palletKeyword.value = '' palletError.value = null if (canLoadPalletOptions.value) void loadPalletOptions() }, @@ -472,6 +490,8 @@ defineExpose({ reload: loadWorkspace }) aria-label="选择称重托盘" placeholder="选择称重托盘" filterable + remote + :remote-method="searchPalletOptions" :loading="palletLoading" :disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)" /> @@ -519,7 +539,7 @@ defineExpose({ reload: loadWorkspace }) :loading="palletLoading" compact class="weight-action-error" - @retry="loadPalletOptions" + @retry="reloadPalletOptions" > @@ -531,7 +551,7 @@ defineExpose({ reload: loadWorkspace }) class="weight-action-error" title="当前仓库暂无可选托盘,暂时不能上传称重图片。" > - 重新加载 + 重新加载
@@ -566,6 +586,7 @@ defineExpose({ reload: loadWorkspace }) @click="openPreview(row)" >{{ cardWeight(row) }}
+
托盘编码
{{ row.palletCode || '未记录编码' }}
托盘类型
{{ row.palletType || '未记录类型' }}
服务端确认毛重
{{ formatWeight(row.grossWeightKg) }}
托盘皮重
{{ formatWeight(row.palletTareWeightKg) }}
diff --git a/src/modules/loading-lists/api/platformLoadingLists.test.ts b/src/modules/loading-lists/api/platformLoadingLists.test.ts index 3ec12ffc..169f26fb 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.test.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.test.ts @@ -54,7 +54,7 @@ const detail = { dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null, internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null, }], - histories: [{ historyId: 21, itemId: 11, actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }], + histories: [{ historyId: 21, itemId: 11, operationId: 'transport-21', actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }], } describe('platform loading lists API', () => { @@ -78,7 +78,7 @@ describe('platform loading lists API', () => { expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' }) expect(result.summary.containerBookingId).toBe('1900000000000000004') - expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operatorId: '22' }) + expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operationId: 'transport-21', operatorId: '22' }) }) it('updates transport with expectedVersion, warehouse and idempotency key', async () => { diff --git a/src/modules/loading-lists/api/platformLoadingLists.ts b/src/modules/loading-lists/api/platformLoadingLists.ts index aed5625e..0042d0e7 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.ts @@ -115,6 +115,7 @@ export interface LoadingItem { export interface LoadingHistory { historyId: string itemId: string | null + operationId: string actionType: string fromStatus: string | null toStatus: string | null @@ -251,6 +252,7 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory { ...raw, historyId: requiredId(raw.historyId), itemId: optionalId(raw.itemId), + operationId: requiredId(raw.operationId), operatorId: requiredId(raw.operatorId), } } diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue index fda3f2f2..1686e14d 100644 --- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue @@ -9,7 +9,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import ContainerFeesPanel from './ContainerFeesPanel.vue' import { loadingListStatusLabel } from '../status' import type { ApiErrorDetails } from '@/shared/api/http' -import type { LoadingDetail, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists' +import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists' const props = defineProps<{ mode: LoadingListMode @@ -56,6 +56,44 @@ const summary = computed(() => props.detail?.summary const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null) const canEditItemRemark = computed(() => props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')) const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns) +const volatileSnapshotFields = new Set(['requestHash', 'version', 'updatedAt', 'operatorId', 'operatorName', 'occurredAt']) + +function stableSnapshotValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableSnapshotValue) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.entries(value as Record) + .filter(([key]) => !volatileSnapshotFields.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, stableSnapshotValue(nested)])) +} + +function normalizedSnapshot(value: string | null) { + if (!value) return null + try { return stableSnapshotValue(JSON.parse(value) as unknown) } catch { return value } +} + +function isPureStatusNoop(history: LoadingHistory) { + return history.actionType === 'STATUS_UPDATE' + && history.fromStatus === history.toStatus + && JSON.stringify(normalizedSnapshot(history.beforeSnapshot)) === JSON.stringify(normalizedSnapshot(history.afterSnapshot)) +} + +function historyEventKey(history: LoadingHistory) { + if (history.operationId) return `operation:${history.operationId}` + return history.historyId ? `history:${history.historyId}` : null +} + +const visibleHistories = computed(() => { + const seen = new Set() + return (props.detail?.histories || []).filter((history) => { + if (isPureStatusNoop(history)) return false + const eventKey = historyEventKey(history) + if (!eventKey) return true + if (seen.has(eventKey)) return false + seen.add(eventKey) + return true + }) +}) const blockingReasonLabels: Record = { PRELOAD_NOT_PUSHED: '尚未推送预装清单', LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态', @@ -188,10 +226,10 @@ function formatNumber(value?: number | null, digits = 3) {
-

操作历史

{{ detail.histories.length }} 条
- +

操作历史

{{ visibleHistories.length }} 条
+ - +
{{ actionLabel(history.actionType) }} {{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }} diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts index f69a1988..df0c1a2a 100644 --- a/src/modules/loading-lists/components/LoadingListsView.test.ts +++ b/src/modules/loading-lists/components/LoadingListsView.test.ts @@ -240,4 +240,19 @@ describe('LoadingListsView', () => { expect(detailSource).not.toContain('customsDocument') expect(detailSource).not.toContain('报关资料') }) + + it('filters pure status no-ops and counts identity-distinct visible history events', () => { + for (const marker of ['visibleHistories', 'STATUS_UPDATE', 'operationId', 'PUSH_PRELOAD']) expect(detailSource).toContain(marker) + expect(detailSource).toContain('visibleHistories.length }} 条') + expect(detailSource).toContain('v-for="history in visibleHistories"') + expect(detailSource).not.toContain('detail.histories.length }} 条') + expect(detailSource).not.toContain('v-for="history in detail.histories"') + expect(detailSource).toContain('seen.has(eventKey)') + expect(detailSource).toContain('history.operationId') + const sameWording = [ + { historyId: '4', operationId: '', actionType: 'ADJUST_ITEM', operationReason: '相同文案' }, + { historyId: '5', operationId: '', actionType: 'ADJUST_ITEM', operationReason: '相同文案' }, + ] + expect(new Set(sameWording.map((history) => history.historyId)).size).toBe(2) + }) })