fix(admin): align loading history and pallet lookup

This commit is contained in:
2026-09-01 11:10:22 +08:00
parent 4711bcc4e6
commit e3ecba7133
8 changed files with 136 additions and 20 deletions
@@ -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)
})
})