This commit is contained in:
2026-08-18 11:33:01 +08:00
parent 834382e04b
commit 114d5bacc7
34 changed files with 522 additions and 570 deletions
@@ -4,32 +4,33 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { downloadWeightImage, downloadWeightThumbnail } from '../weight/api/weightOcr'
import WeightImageThumbnail from '../weight/components/WeightImageThumbnail.vue'
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory, rejectMeasurement } from './api'
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
import type { MeasurementReviewDetail } from './types'
vi.mock('./api', () => ({
approveMeasurement: vi.fn(),
getMeasurementReviewDetail: vi.fn(),
listMeasurementReviewHistory: vi.fn(),
rejectMeasurement: vi.fn(),
releaseRejectedMeasurement: vi.fn(),
}))
vi.mock('../weight/api/weightOcr', () => ({
downloadWeightImage: vi.fn(),
downloadWeightThumbnail: vi.fn(),
vi.mock('../weight/components/WeightOcrPanel.vue', () => ({
default: {
name: 'WeightOcrPanel',
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean },
emits: ['changed'],
template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
},
}))
const detail = {
const detail: MeasurementReviewDetail = {
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
measurementId: '70001', sourceType: 'DEVICE', measurementStatus: 'SUBMITTED', inboundVersion: 7, version: 4,
originalDocumentNo: 'ORD-001', originalGrossWeightKg: 100, originalVolumeCbm: 2, originalPackageCount: 10,
actualPackageCount: 10, actualGrossWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物',
items: [{ id: '1', boxCode: 'RK90001#10-1', itemNo: '001', boxSpecification: '60×40×30', packageCount: 10, lengthCm: 60, widthCm: 40, heightCm: 30, girthCm: 240, unitWeightKg: 11, unitVolumeCbm: 0.072, totalWeightKg: 110, totalVolumeCbm: 0.72, volumeWeightKg: 120.24, intelligentImageFileId: '30001', machineNo: 'MJ20210327', measuredAt: '2026-08-04T10:30:00+08:00', overLimitWarning: '周长超过渠道限制,请人工核验尺寸后再作出审核决定', outerLengthCm: 61, outerWidthCm: 41, outerHeightCm: 31, remark: '外箱存在轻微变形' }],
weightResults: [{ id: '2', fileId: 'file-1', palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', palletCapacityCbm: 1.2, palletTareWeightKg: 10, ocrProvider: 'OCR', ocrRawText: '设备屏幕原始识别内容 120.5 KG,包含一段需要自动换行展示的较长文本', ocrWeightValue: 120.5, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: 120, adjustedWeightUnit: 'KG', grossWeightKg: 120, netWeightKg: 110, recognitionStatus: 'CONFIRMED', adjustmentReason: '修正 OCR 毛重读数', adjustedBy: '20001', adjustedAt: '2026-08-04T09:58:00', confirmedBy: '20002', confirmedAt: '2026-08-04T10:00:00', version: 1 }],
actualPackageCount: 10, actualGrossWeightKg: 120, actualNetWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物',
items: [],
weightResults: [],
}
enableAutoUnmount(afterEach)
@@ -37,181 +38,86 @@ enableAutoUnmount(afterEach)
describe('MeasurementReviewPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => blob.size > 5 ? 'blob:weight-original' : 'blob:weight-thumbnail'),
})
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['firstleg:inbound:measurement-review', 'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release']
auth.permissions = [
'firstleg:inbound:measurement-review',
'firstleg:inbound:measurement-review-history',
'platform:firstleg:inbound:measure:view',
'platform:firstleg:inbound:weight:edit',
'platform:firstleg:inbound:weight:confirm',
]
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail)
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['original-image'], { type: 'image/jpeg' }))
})
it('keeps the review focused on weight evidence and omits carton measurement details', async () => {
it('keeps the pending decision and refresh actions in a sticky first-screen toolbar', async () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('入仓码')
expect(wrapper.text()).toContain('RK90001')
expect(wrapper.text()).toContain('柏悦客户 · 海运')
expect(wrapper.text()).toContain('合计货物重量')
expect(wrapper.text()).toContain('全部称重图片')
expect(wrapper.text()).toContain('托盘数量')
expect(wrapper.text()).toContain('1 托')
expect(wrapper.text()).toContain('称重图片')
expect(wrapper.text()).toContain('货物重量')
expect(wrapper.text()).toContain('110 kg')
expect(wrapper.text()).toContain('托盘类型:木托盘')
expect(wrapper.text()).toContain('服务端确认毛重:120 kg')
expect(wrapper.text()).toContain('托盘皮重:10 kg')
expect(wrapper.text()).not.toContain('TP-001')
expect(wrapper.text()).not.toContain('申报毛重')
expect(wrapper.text()).not.toContain('OCR 原文')
expect(wrapper.text()).not.toContain('设备屏幕原始识别内容')
expect(wrapper.text()).not.toContain('置信度')
expect(wrapper.text()).not.toContain('调整原因')
expect(wrapper.findAllComponents(WeightImageThumbnail)).toHaveLength(1)
expect(wrapper.text()).not.toContain('测量明细')
expect(wrapper.text()).not.toContain('实测长 × 宽 × 高(cm)')
expect(wrapper.text()).not.toContain('设备实测体积')
expect(wrapper.text()).not.toContain('材积重')
expect(wrapper.text()).not.toContain('租户')
expect(wrapper.text()).toContain('通过并进入待分柜')
expect(wrapper.text()).not.toContain('确认费用')
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
expect(downloadWeightThumbnail).toHaveBeenCalledWith('10001', '90001', 'file-1')
const toolbar = wrapper.get('.review-toolbar')
expect(toolbar.text()).toContain('待审核')
expect(toolbar.text()).toContain('修改重量')
expect(toolbar.text()).toContain('通过并进入待分柜')
expect(toolbar.text()).toContain('刷新')
expect(toolbar.text()).toContain('客户:柏悦客户')
expect(toolbar.text()).toContain('渠道:海运')
expect(wrapper.find('.review-toolbar__context').exists()).toBe(true)
expect(wrapper.find('.review-context').exists()).toBe(false)
expect(wrapper.text()).not.toContain('RK90001')
expect(wrapper.text()).not.toContain('入仓码')
expect(wrapper.text()).not.toContain('驳回')
expect(wrapper.text()).not.toContain('异常放行')
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
expect(source).toContain('defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })')
expect(source).not.toContain('measurementColumns')
expect(source.indexOf('全部称重图片')).toBeLessThan(source.indexOf('审核结论'))
expect(source).not.toContain('<DataTable')
expect(source).toContain('<el-collapse class="review-history">')
expect(source).toContain('position: sticky')
expect(source).toContain('review-toolbar__main')
expect(source).toContain('review-toolbar__context')
expect(source.indexOf('class="review-toolbar"')).toBeLessThan(source.indexOf('<WeightOcrPanel'))
expect(source).not.toContain('reasonDialog')
expect(source).not.toContain('saveInboundMeasurementDraft')
expect(source).not.toContain('submitInboundMeasurement')
})
it('shows every weight image at once with its cargo weight below', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({
...detail,
actualGrossWeightKg: 330,
weightResults: [
detail.weightResults[0],
{ ...detail.weightResults[0], id: '3', fileId: 'file-2', netWeightKg: 100 },
{ ...detail.weightResults[0], id: '4', fileId: 'file-3', netWeightKg: 120 },
],
})
it('reuses WeightOcrPanel and enables only its review correction mode', async () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('.weight-evidence-card')).toHaveLength(3)
expect(wrapper.findAllComponents(WeightImageThumbnail)).toHaveLength(3)
expect(wrapper.findAll('.weight-evidence-card__weight').map((card) => card.text())).toEqual([
'货物重量110 kg',
'货物重量100 kg',
'货物重量120 kg',
])
expect(wrapper.find('.weight-overview').text()).toContain('330 kg')
expect(wrapper.find('.weight-overview').text()).toContain('3 托')
expect(downloadWeightThumbnail).toHaveBeenCalledTimes(3)
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
expect(weightPanel.props('reviewMode')).toBe(true)
expect(weightPanel.props('correctionMode')).toBe(false)
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
expect(weightPanel.props('correctionMode')).toBe(true)
})
it('opens the authenticated original image with the reviewed weight facts', async () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] }, attachTo: document.body })
await flushPromises()
await wrapper.find('.weight-evidence-card .weight-thumb').trigger('click')
await flushPromises()
expect(downloadWeightImage).toHaveBeenCalledWith('10001', '90001', 'file-1')
const preview = document.body.querySelector('.weight-preview')
expect(preview?.textContent).toContain('托盘类型')
expect(preview?.textContent).toContain('木托盘')
expect(preview?.textContent).not.toContain('TP-001')
expect(preview?.textContent).toContain('120 kg')
expect(preview?.textContent).toContain('10 kg')
expect(preview?.textContent).toContain('110 kg')
expect(preview?.querySelector('img')?.getAttribute('src')).toBe('blob:weight-original')
})
it('shows the empty pallet type explicitly without falling back to the pallet code', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValueOnce({
...detail,
weightResults: [{ ...detail.weightResults[0], palletType: '' }],
})
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('托盘类型:未记录类型')
expect(wrapper.text()).not.toContain('TP-001')
})
it('shows the rejected release action only when the permission and rejected state exist', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '重量图片不清晰' })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('异常放行')
expect(wrapper.text()).toContain('重量图片不清晰')
const auth = useAuthStore()
auth.permissions = ['firstleg:inbound:measurement-review-history']
useAccessPreviewStore().initializeAfterLogin()
await wrapper.vm.$nextTick()
expect(wrapper.findAll('button').some((button) => button.text().includes('异常放行'))).toBe(false)
})
it('emits changed after a successful review write', async () => {
it('refreshes inboundVersion after a weight correction before approval', async () => {
vi.mocked(getMeasurementReviewDetail)
.mockResolvedValueOnce(detail)
.mockResolvedValueOnce({ ...detail, inboundVersion: 8, actualGrossWeightKg: 125, actualNetWeightKg: 115 })
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('通过并进入待分柜'))
expect(approve).toBeDefined()
await approve?.trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
expect.stringContaining('初算失败不影响审核通过'),
'确认测量审核通过',
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 wrapper.get('.mock-weight-change').trigger('click')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
await flushPromises()
expect(approveMeasurement).toHaveBeenCalledOnce()
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(3)
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 8 })
expect(wrapper.emitted('changed')).toHaveLength(1)
expect(wrapper.text()).not.toContain('初算失败不阻断审核')
})
it('rejects with the inbound optimistic-lock version instead of the measurement version', async () => {
vi.mocked(rejectMeasurement).mockResolvedValue(undefined)
it('shows historical rejected data read-only without new reject or release operations', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '历史审核说明' })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const reject = wrapper.findAll('button').find((button) => button.text().includes('驳回'))
await reject?.trigger('click')
await wrapper.find('textarea').setValue('重量图片需要重新核对')
const confirm = wrapper.findAll('button').find((button) => button.text().includes('确认驳回'))
await confirm?.trigger('click')
await flushPromises()
expect(rejectMeasurement).toHaveBeenCalledWith('10001', '90001', {
measurementId: '70001',
expectedVersion: 7,
reason: '重量图片需要重新核对',
})
expect(wrapper.text()).toContain('历史记录为审核驳回,仅供查看')
expect(wrapper.text()).toContain('历史审核说明')
expect(wrapper.text()).not.toContain('修改重量')
expect(wrapper.text()).not.toContain('异常放行')
})
})
@@ -1,21 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, CircleAlert, RotateCcw, ShieldAlert, X } from '@lucide/vue'
import { Check, Pencil, RotateCcw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import { downloadWeightImage } from '../weight/api/weightOcr'
import WeightImageThumbnail from '../weight/components/WeightImageThumbnail.vue'
import {
approveMeasurement,
getMeasurementReviewDetail,
listMeasurementReviewHistory,
rejectMeasurement,
releaseRejectedMeasurement,
} from './api'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
import type { MeasurementReviewDetail, MeasurementReviewRecord } from './types'
const props = defineProps<{
@@ -25,9 +18,7 @@ const props = defineProps<{
inboundId: string
}>()
const emit = defineEmits<{
changed: []
}>()
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const detail = ref<MeasurementReviewDetail | null>(null)
@@ -37,35 +28,17 @@ const historyLoading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const historyError = ref<ApiErrorDetails | null>(null)
const reasonDialog = ref<'reject' | 'release' | null>(null)
const reason = ref('')
const correctionMode = ref(false)
const requestSerial = ref(0)
const previewOpen = ref(false)
const previewUrl = ref('')
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewResult = ref<MeasurementReviewDetail['weightResults'][number] | null>(null)
const previewIndex = ref(0)
let previewRequestSerial = 0
const reasonDialogOpen = computed({
get: () => reasonDialog.value !== null,
set: (open: boolean) => { if (!open) reasonDialog.value = null },
})
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
const canRelease = computed(() => access.can('firstleg:inbound:exception-release'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
function formatNumber(value: number | null | undefined, unit = '') {
if (value === null || value === undefined || Number.isNaN(value)) return '-'
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '-'
return value ? value.replace('T', ' ') : '—'
}
function resultLabel(value: string | null | undefined) {
@@ -74,35 +47,6 @@ function resultLabel(value: string | null | undefined) {
return '未知结果'
}
function releasePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
async function openWeightImage(row: unknown, index: number) {
previewResult.value = row as MeasurementReviewDetail['weightResults'][number]
previewIndex.value = index
previewOpen.value = true
await loadPreviewImage()
}
async function loadPreviewImage() {
const row = previewResult.value
if (!row?.fileId) return
const serial = ++previewRequestSerial
releasePreview()
previewError.value = null
previewLoading.value = true
try {
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
if (serial === previewRequestSerial) previewUrl.value = URL.createObjectURL(blob)
} catch (error) {
if (serial === previewRequestSerial) previewError.value = getApiErrorDetails(error, '称重原图读取失败,请重新加载')
} finally {
if (serial === previewRequestSerial) previewLoading.value = false
}
}
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return details.message.includes('版本已变化') || details.message.includes('并发修改')
@@ -129,8 +73,7 @@ async function loadHistory() {
historyError.value = null
historyLoading.value = true
try {
const result = await listMeasurementReviewHistory(customerId.value, props.inboundId)
history.value = result.records
history.value = (await listMeasurementReviewHistory(customerId.value, props.inboundId)).records
} catch (error) {
history.value = []
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
@@ -143,6 +86,10 @@ async function reload() {
await Promise.all([loadReview(), loadHistory()])
}
async function handleWeightChanged() {
await loadReview()
}
async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return
try {
@@ -160,11 +107,8 @@ async function confirmApprove() {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
})
if (result.initialPricingSucceeded) {
ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
} else {
ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
}
if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
await reload()
emit('changed')
} catch (error) {
@@ -175,48 +119,8 @@ async function confirmApprove() {
}
}
function openReasonDialog(kind: 'reject' | 'release') {
reason.value = ''
reasonDialog.value = kind
}
async function submitReason() {
if (!detail.value || !reasonDialog.value) return
const value = reason.value.trim()
if (!value) {
ElMessage.warning(reasonDialog.value === 'reject' ? '请填写驳回原因' : '请填写异常放行原因')
return
}
const kind = reasonDialog.value
actionLoading.value = kind
try {
if (kind === 'reject') {
await rejectMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
reason: value,
})
ElMessage.success('测量已驳回,已退回测量修正')
} else {
await releaseRejectedMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
reason: value,
})
ElMessage.success('异常放行已提交,后续计费入库将继续处理')
}
reasonDialog.value = null
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重试' : getApiErrorDetails(error, kind === 'reject' ? '测量驳回失败' : '异常放行失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
watch(() => [customerId.value, props.inboundId], () => {
correctionMode.value = false
if (customerId.value && props.inboundId) void reload()
}, { immediate: true })
@@ -224,87 +128,45 @@ onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
watch(previewOpen, (open) => {
if (!open) {
previewRequestSerial += 1
previewResult.value = null
previewError.value = null
releasePreview()
}
})
onBeforeUnmount(releasePreview)
defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })
defineExpose({ confirmApprove, isPending, canReview, actionLoading })
</script>
<template>
<section class="review-panel" aria-labelledby="measurement-review-title">
<header class="review-header">
<div>
<span>入仓码</span>
<h2 id="measurement-review-title">{{ detail?.inboundCode || '-' }}</h2>
<p>{{ detail ? `${detail.companyName || '客户未命名'} · ${detail.channelName || detail.channelCode || '渠道未填写'}` : '称重结果' }}</p>
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
<div class="review-toolbar" aria-label="测量审核操作">
<div class="review-toolbar__main">
<div class="review-toolbar__status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<span v-if="isPending">通过后进入待分柜</span>
<span v-else-if="isRejected">历史记录为审核驳回,仅供查看</span>
<span v-else>当前记录不可审核</span>
</div>
<div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
<el-button :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</div>
<div class="review-header-status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</header>
<p v-if="detail" class="review-toolbar__context">客户:{{ detail.companyName || '客户未命名' }} · 渠道:{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
</div>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="review-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="reload">重新加载</el-button>
</el-alert>
<el-skeleton v-if="loading && !detail" :rows="8" animated />
<template v-else-if="detail">
<dl class="weight-overview" aria-label="重量审核摘要">
<div class="weight-overview__primary"><dt>合计货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
<div><dt>托盘数量</dt><dd>{{ detail.weightResults.length }} 托</dd></div>
</dl>
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
<div v-if="detail.reviewReason" class="review-reason">
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
</div>
<div class="review-section weight-evidence-section">
<div class="section-heading"><h3>全部称重图片</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
<div v-if="detail.weightResults.length" class="weight-evidence-grid">
<article v-for="(row, index) in detail.weightResults" :key="row.id" class="weight-evidence-card">
<div class="weight-evidence-card__image">
<WeightImageThumbnail
v-if="row.fileId"
:customer-id="customerId"
:inbound-id="inboundId"
:file-id="row.fileId"
:alt="`称重图片 ${index + 1}`"
@open="openWeightImage(row, index)"
/>
<span v-else class="missing-evidence">未关联图片</span>
</div>
<div class="weight-evidence-card__weight">
<span>货物重量</span>
<strong>{{ formatNumber(row.netWeightKg, ' kg') }}</strong>
</div>
<div class="weight-evidence-card__record">
<span>托盘类型:{{ row.palletType || '未记录类型' }}</span>
<span>服务端确认毛重:{{ formatNumber(row.grossWeightKg, ' kg') }}</span>
<span>托盘皮重:{{ formatNumber(row.palletTareWeightKg, ' kg') }}</span>
</div>
</article>
</div>
<el-empty v-else description="暂无称重图片" :image-size="56" />
</div>
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
<div><strong>审核结论</strong><span v-if="isPending">通过后进入待分柜</span><span v-else-if="isRejected">已驳回,可修正后重新提交</span><span v-else>当前记录不可审核</span></div>
<div class="action-buttons">
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
<el-button v-if="isRejected && canRelease" type="warning" plain :loading="actionLoading === 'release'" @click="openReasonDialog('release')"><ShieldAlert :size="16" />异常放行</el-button>
</div>
</div>
<WeightOcrPanel
review-mode
:correction-mode="correctionMode"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
/>
<el-collapse class="review-history">
<el-collapse-item name="history" :title="`审核历史(${history.length})`">
@@ -320,64 +182,39 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
</el-collapse-item>
</el-collapse>
</template>
<el-dialog v-model="previewOpen" :title="`称重原图 ${previewIndex + 1}`" width="min(880px, calc(100vw - 28px))" destroy-on-close append-to-body>
<div v-if="previewResult" class="weight-preview">
<div v-loading="previewLoading" class="weight-preview__canvas">
<img v-if="previewUrl" :src="previewUrl" :alt="`称重原图 ${previewIndex + 1}`" />
<el-alert v-else-if="previewError" type="error" :closable="false" show-icon>
<template #title><div class="review-alert-title"><span>{{ previewError.message }}</span><ApiErrorMeta :details="previewError" /></div></template>
<el-button link type="danger" :loading="previewLoading" @click="loadPreviewImage">重新加载原图</el-button>
</el-alert>
</div>
<dl class="weight-preview__facts">
<div><dt>托盘类型</dt><dd>{{ previewResult.palletType || '未记录类型' }}</dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatNumber(previewResult.grossWeightKg, ' kg') }}</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatNumber(previewResult.palletTareWeightKg, ' kg') }}</dd></div>
<div><dt>货物重量</dt><dd>{{ formatNumber(previewResult.netWeightKg, ' kg') }}</dd></div>
</dl>
</div>
</el-dialog>
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="min(520px, calc(100vw - 28px))" destroy-on-close>
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
<template #footer><el-button :disabled="Boolean(actionLoading)" @click="reasonDialog = null">取消</el-button><el-button :type="reasonDialog === 'reject' ? 'danger' : 'warning'" :loading="Boolean(actionLoading)" @click="submitReason">确认{{ reasonDialog === 'reject' ? '驳回' : '放行' }}</el-button></template>
</el-dialog>
</section>
</template>
<style scoped>
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
.review-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.review-header > div:first-child > span { color: var(--cc-muted); font-size: 11px; }
.review-header h2 { margin: 0; overflow-wrap: anywhere; font-size: 20px; line-height: 1.25; }
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.review-toolbar { position: sticky; z-index: 4; top: 0; display: grid; gap: 10px; margin: -20px -24px 0; padding: 14px 24px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; box-shadow: 0 4px 12px rgb(16 24 40 / 6%); }
.review-toolbar__main { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 14px; }
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
.review-toolbar__status > span { color: var(--cc-muted); font-size: 12px; }
.review-toolbar__actions { justify-content: flex-end; }
.review-toolbar__actions :deep(.el-button) { margin-left: 0; }
.review-toolbar__context { margin: 0; padding-top: 10px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.weight-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
.weight-overview > div { display: grid; align-content: center; min-width: 0; min-height: 82px; gap: 7px; padding: 14px 18px; border-right: 1px solid var(--cc-border); }
.weight-overview > div:last-child { border-right: 0; }.weight-overview dt { color: var(--cc-muted); font-size: 12px; }.weight-overview dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 17px; font-weight: 700; overflow-wrap: anywhere; }.weight-overview__primary { background: #f0f7ff; }.weight-overview__primary dd { color: var(--cc-primary); font-size: 24px; }
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.weight-evidence-section { min-width: 0; }
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.weight-evidence-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.weight-evidence-card__image { display: grid; aspect-ratio: 4 / 3; overflow: hidden; place-items: center; background: #f2f4f7; }
.weight-evidence-card__image :deep(.weight-thumb) { height: 100%; border-radius: 0; }
.weight-evidence-card__weight { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; }
.weight-evidence-card__weight span { color: var(--cc-muted); font-size: 12px; }
.weight-evidence-card__weight strong { color: #067647; font-size: 20px; overflow-wrap: anywhere; text-align: right; }
.weight-evidence-card__record { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 12px; padding: 0 14px 12px; color: var(--cc-muted); font-size: 12px; }
.missing-evidence { color: var(--cc-muted); font-size: 12px; }
.weight-preview { display: grid; gap: 14px; }.weight-preview__canvas { display: grid; min-height: 320px; max-height: 62vh; overflow: hidden; place-items: center; border: 1px solid var(--cc-border); border-radius: 6px; background: #f2f4f7; }.weight-preview__canvas img { display: block; max-width: 100%; max-height: 62vh; object-fit: contain; }.weight-preview__canvas .el-alert { width: min(520px, calc(100% - 24px)); }.weight-preview__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; }.weight-preview__facts > div { display: grid; min-width: 0; gap: 5px; padding: 12px 14px; border-right: 1px solid var(--cc-border); }.weight-preview__facts > div:last-child { border-right: 0; }.weight-preview__facts dt { color: var(--cc-muted); font-size: 12px; }.weight-preview__facts dd { min-width: 0; margin: 0; font-weight: 700; overflow-wrap: anywhere; }
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.review-history { border-top: 1px solid var(--cc-border); border-bottom: 0; }
.review-history :deep(.el-collapse-item__header) { height: 42px; color: var(--cc-muted); font-size: 12px; }
.review-history :deep(.el-collapse-item__wrap) { border-bottom: 0; }
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.weight-overview { grid-template-columns: 1fr; }.weight-overview > div { min-height: 68px; border-right: 0; border-bottom: 1px solid var(--cc-border); }.weight-overview > div:last-child { border-bottom: 0; }.weight-evidence-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.weight-preview__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }.weight-preview__facts > div:nth-child(2) { border-right: 0; }.weight-preview__facts > div:nth-child(-n + 2) { border-bottom: 1px solid var(--cc-border); }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.weight-overview { grid-template-columns: 1fr; }.weight-overview > div { min-height: 68px; border-right: 0; border-bottom: 1px solid var(--cc-border); }.weight-overview > div:last-child { border-bottom: 0; }.weight-overview__primary dd { font-size: 21px; }.weight-evidence-grid { grid-template-columns: 1fr; }.review-actions { padding: 13px; } }
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }
.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }
.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }
.history-marker--success { border-color: #67c23a; }
.history-marker--danger { border-color: #f56c6c; }
.history-content { min-width: 0; }
.history-content > div { display: flex; justify-content: space-between; gap: 12px; }
.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }
.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.review-toolbar { margin: -14px -14px 0; padding: 12px 14px; }
.review-toolbar__main { align-items: stretch; flex-direction: column; }
.review-toolbar__actions { display: grid; width: 100%; grid-template-columns: 1fr; justify-content: stretch; }
.review-toolbar__actions :deep(.el-button) { width: 100%; min-height: 44px; }
}
@media (max-width: 360px) {
.review-toolbar { margin: -10px -10px 0; padding: 10px; }
}
</style>
+1 -7
View File
@@ -4,8 +4,6 @@ import {
approveMeasurement,
getMeasurementReviewDetail,
listMeasurementReviewHistory,
rejectMeasurement,
releaseRejectedMeasurement,
} from './api'
vi.mock('@/shared/api/http', () => ({
@@ -37,11 +35,7 @@ describe('measurement review API', () => {
await expect(listMeasurementReviewHistory('10001', '90001', 2, 20)).resolves.toMatchObject({ page: 2, total: 3 })
await approveMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4 }, 'approve-key')
await rejectMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, 'reject-key')
await releaseRejectedMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, 'release-key')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/90001/measurements/approve', { measurementId: '7', expectedVersion: 4 }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'approve-key' } })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurements/reject', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'reject-key' } })
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/inbounds/90001/measurements/exception-release', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'release-key' } })
expect(http.post).toHaveBeenCalledTimes(1)
})
})
-29
View File
@@ -1,6 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
ExceptionReleaseRequest,
MeasurementApprovalResponse,
MeasurementReviewDetail,
MeasurementReviewHistoryPage,
@@ -70,31 +69,3 @@ export function approveMeasurement(
key,
)
}
export function rejectMeasurement(
tenantId: string,
inboundId: string,
request: MeasurementReviewRequest,
key?: string,
) {
return postReview<void>(
`/platform/v1/inbounds/${inboundId}/measurements/reject`,
tenantId,
request,
key,
)
}
export function releaseRejectedMeasurement(
tenantId: string,
inboundId: string,
request: ExceptionReleaseRequest,
key?: string,
) {
return postReview<void>(
`/platform/v1/inbounds/${inboundId}/measurements/exception-release`,
tenantId,
request,
key,
)
}
+1 -6
View File
@@ -74,6 +74,7 @@ export interface MeasurementReviewDetail {
originalPackageCount: number | null
actualPackageCount: number | null
actualGrossWeightKg: number | null
actualNetWeightKg: number | null
actualVolumeCbm: number | null
volumeWeightKg: number | null
actualGoodsName: string | null
@@ -116,12 +117,6 @@ export interface MeasurementReviewRequest {
reason?: string
}
export interface ExceptionReleaseRequest {
measurementId: string
expectedVersion: number
reason: string
}
export interface MeasurementApprovalResponse {
inboundId: string | null
measurementId: string | null