Release/test 合并上生产 #2
@@ -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 },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<WeightPalletOptionPageResponse>, ApiEnvelope<WeightPalletOptionPageResponse>>(
|
||||
`${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 {
|
||||
|
||||
@@ -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([{
|
||||
|
||||
@@ -45,6 +45,7 @@ const fileInput = ref<HTMLInputElement>()
|
||||
const workspace = ref<WeightWorkspaceResource | null>(null)
|
||||
const palletOptions = ref<WeightPalletOption[]>([])
|
||||
const selectedPalletId = ref('')
|
||||
const palletKeyword = ref('')
|
||||
const palletLoading = ref(false)
|
||||
const palletError = ref<ApiErrorDetails | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -61,6 +62,7 @@ const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB
|
||||
let loadSequence = 0
|
||||
let palletLoadSequence = 0
|
||||
let palletAbortController: AbortController | null = null
|
||||
const PALLET_OPTION_PAGE_SIZE = 50
|
||||
|
||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
|
||||
@@ -225,7 +227,7 @@ async function loadWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPalletOptions() {
|
||||
async function loadPalletOptions(keyword = palletKeyword.value) {
|
||||
if (!canLoadPalletOptions.value) {
|
||||
cancelPalletOptionsLoad()
|
||||
palletOptions.value = []
|
||||
@@ -237,10 +239,16 @@ async function loadPalletOptions() {
|
||||
const sequence = ++palletLoadSequence
|
||||
const controller = new AbortController()
|
||||
palletAbortController = controller
|
||||
palletKeyword.value = keyword.trim()
|
||||
palletLoading.value = true
|
||||
palletError.value = null
|
||||
try {
|
||||
const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal)
|
||||
const result = await getWeightPalletOptions(customerId.value, props.inboundId, {
|
||||
page: 1,
|
||||
size: PALLET_OPTION_PAGE_SIZE,
|
||||
keyword: palletKeyword.value || undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (sequence !== palletLoadSequence || controller.signal.aborted) return
|
||||
palletOptions.value = result.records
|
||||
if (!result.records.some((option) => 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"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="palletError" /></template>
|
||||
</LoadErrorState>
|
||||
@@ -531,7 +551,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
class="weight-action-error"
|
||||
title="当前仓库暂无可选托盘,暂时不能上传称重图片。"
|
||||
>
|
||||
<el-button size="small" @click="loadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
|
||||
<el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
|
||||
</el-alert>
|
||||
<div v-loading="loading" class="weight-list" aria-live="polite">
|
||||
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
||||
@@ -566,6 +586,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
@click="openPreview(row)"
|
||||
><strong>{{ cardWeight(row) }}</strong></button>
|
||||
<dl class="weight-card__facts">
|
||||
<div><dt>托盘编码</dt><dd :title="row.palletCode">{{ row.palletCode || '未记录编码' }}</dd></div>
|
||||
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
|
||||
<div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
|
||||
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LoadingListSummary | null>(() => 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<string, unknown>)
|
||||
.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<string>()
|
||||
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<string, string> = {
|
||||
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
|
||||
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
|
||||
@@ -188,10 +226,10 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
|
||||
<section class="history-section" aria-label="操作历史">
|
||||
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
|
||||
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
|
||||
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
|
||||
<el-empty v-if="visibleHistories.length === 0" description="暂无操作历史" :image-size="72" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="history in detail.histories" :key="history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
|
||||
<el-timeline-item v-for="history in visibleHistories" :key="historyEventKey(history) || history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
|
||||
<div class="history-entry">
|
||||
<strong>{{ actionLabel(history.actionType) }}</strong>
|
||||
<span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user