优化
This commit is contained in:
@@ -201,7 +201,7 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(text).toContain('扫描箱测量数据明细')
|
||||
expect(text).toContain('订单总箱数')
|
||||
expect(text).toContain('确认箱数合计')
|
||||
expect(text).toContain('实测总毛重')
|
||||
expect(text).toContain('货物总重量')
|
||||
expect(text).toContain('实测总体积')
|
||||
expect(text).toContain('实测总材积重')
|
||||
expect(text).not.toContain('租户')
|
||||
@@ -271,6 +271,22 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(getInboundMeasurement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows a normal A0409 business reason instead of relabeling it as a version conflict', async () => {
|
||||
vi.mocked(updateConfirmedPackageCount).mockRejectedValueOnce(
|
||||
new ApiResponseError('该托盘已被占用,请重新选择', 'A0409', 409),
|
||||
)
|
||||
const wrapper = await mountPanel()
|
||||
const countInput = wrapper.find('.measurement-count-editor input')
|
||||
await countInput.setValue('9')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('该托盘已被占用,请重新选择')
|
||||
expect(wrapper.text()).toContain('操作未完成')
|
||||
expect(wrapper.text()).not.toContain('数据版本已变化,当前修改尚未丢失')
|
||||
})
|
||||
|
||||
it('requires the dirty draft to be saved before submitting for review', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const countInput = wrapper.find('.measurement-count-editor input')
|
||||
|
||||
@@ -314,11 +314,8 @@ function validateDraft() {
|
||||
function setOperationError(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
operationError.value = details
|
||||
conflictDetected.value = details.code === 'A0409'
|
||||
|| details.code === 'CONFLICT'
|
||||
|| details.code === 'HTTP_409'
|
||||
|| details.message.includes('版本')
|
||||
|| details.message.includes('并发')
|
||||
conflictDetected.value = details.message.includes('版本已变化')
|
||||
|| details.message.includes('并发修改')
|
||||
return details
|
||||
}
|
||||
|
||||
@@ -532,7 +529,7 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
||||
<label><span>数据版本</span><el-input :model-value="String(form.version)" readonly /></label>
|
||||
<label><span>确认箱数合计</span><el-input :model-value="formatNumber(confirmedPackageCount, 0)" readonly /></label>
|
||||
<label><span>订单总箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
|
||||
<label><span>实测总毛重(T.K.GW)</span><el-input :model-value="formatNumber(detail.actualGrossWeightKg)" readonly /></label>
|
||||
<label><span>货物总重量(kg)</span><el-input :model-value="formatNumber(detail.actualGrossWeightKg)" readonly /></label>
|
||||
<label><span>原单总毛重(T.K.GW)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
|
||||
<label><span>实测总体积(T.CBM)</span><el-input :model-value="formatNumber(detail.actualVolumeCbm, 4)" readonly /></label>
|
||||
<label><span>原单总体积(T.CBM)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface InboundMeasurementItem {
|
||||
export interface InboundMeasurementWeightResult {
|
||||
id: MeasurementIdentifier
|
||||
fileId: MeasurementIdentifier
|
||||
palletId: MeasurementIdentifier
|
||||
palletCode: string
|
||||
palletType: string
|
||||
palletCapacityCbm: number
|
||||
palletTareWeightKg: number
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
@@ -46,7 +51,8 @@ export interface InboundMeasurementWeightResult {
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
grossWeightKg: number | null
|
||||
netWeightKg: number | null
|
||||
recognitionStatus: string
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: MeasurementIdentifier | null
|
||||
|
||||
@@ -23,7 +23,7 @@ const detail = {
|
||||
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', ocrProvider: 'OCR', ocrRawText: '设备屏幕原始识别内容 110.5 KG,包含一段需要自动换行展示的较长文本', ocrWeightValue: 110.5, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: 110, adjustedWeightUnit: 'KG', finalWeightKg: 110, recognitionStatus: 'CONFIRMED', adjustmentReason: '去除托盘重量后采用净值', adjustedBy: '20001', adjustedAt: '2026-08-04T09:58:00', confirmedBy: '20002', confirmedAt: '2026-08-04T10:00:00', version: 1 }],
|
||||
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 }],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
@@ -48,11 +48,14 @@ describe('MeasurementReviewPanel', () => {
|
||||
expect(wrapper.text()).toContain('设备实测')
|
||||
expect(wrapper.text()).toContain('设备采集')
|
||||
expect(wrapper.text()).not.toContain('DEVICE')
|
||||
expect(wrapper.text()).toContain('称重 OCR')
|
||||
expect(wrapper.text()).toContain('货物总重量')
|
||||
expect(wrapper.text()).toContain('实测长 × 宽 × 高(cm)')
|
||||
expect(wrapper.text()).toContain('周长超过渠道限制')
|
||||
expect(wrapper.text()).toContain('OCR 原文:设备屏幕原始识别内容')
|
||||
expect(wrapper.text()).toContain('调整原因:去除托盘重量后采用净值')
|
||||
expect(wrapper.text()).toContain('TP-001')
|
||||
expect(wrapper.text()).toContain('托盘皮重')
|
||||
expect(wrapper.text()).toContain('货物重量')
|
||||
expect(wrapper.text()).toContain('调整原因:修正 OCR 毛重读数')
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.text()).toContain('通过并进入待分柜')
|
||||
|
||||
@@ -49,7 +49,7 @@ 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 confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.finalWeightKg !== null).length ?? 0)
|
||||
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.netWeightKg !== null).length ?? 0)
|
||||
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
||||
|
||||
const measurementColumns: DataTableColumn[] = [
|
||||
@@ -69,10 +69,13 @@ const measurementColumns: DataTableColumn[] = [
|
||||
|
||||
const weightColumns: DataTableColumn[] = [
|
||||
{ key: 'file', label: '称重文件 / OCR 原文', minWidth: 260, slotName: 'file' },
|
||||
{ key: 'pallet', label: '托盘', minWidth: 170, slotName: 'pallet' },
|
||||
{ key: 'status', label: '识别状态', width: 100, slotName: 'status' },
|
||||
{ key: 'ocrWeight', label: '原始重量', minWidth: 110, slotName: 'ocrWeight' },
|
||||
{ key: 'adjustedWeight', label: '调整后重量', minWidth: 120, slotName: 'adjustedWeight' },
|
||||
{ key: 'finalWeight', label: '最终重量', minWidth: 110, slotName: 'finalWeight' },
|
||||
{ key: 'ocrWeight', label: 'OCR 含托盘毛重', minWidth: 140, slotName: 'ocrWeight' },
|
||||
{ key: 'adjustedWeight', label: '人工修正毛重', minWidth: 130, slotName: 'adjustedWeight' },
|
||||
{ key: 'grossWeight', label: '服务端毛重', minWidth: 115, slotName: 'grossWeight' },
|
||||
{ key: 'tareWeight', label: '托盘皮重', minWidth: 110, slotName: 'tareWeight' },
|
||||
{ key: 'netWeight', label: '货物重量', minWidth: 110, slotName: 'netWeight' },
|
||||
{ key: 'confidence', label: '置信度', width: 90, slotName: 'confidence' },
|
||||
{ key: 'reason', label: '调整原因', minWidth: 180, slotName: 'reason' },
|
||||
]
|
||||
@@ -103,7 +106,7 @@ function measurementSourceLabel(value: string | null | undefined) {
|
||||
|
||||
function isConflict(error: unknown) {
|
||||
const details = getApiErrorDetails(error)
|
||||
return details.code === 'A0409' || details.code === '409' || /并发|版本|已被更新|状态已变化/.test(details.message)
|
||||
return details.message.includes('版本已变化') || details.message.includes('并发修改')
|
||||
}
|
||||
|
||||
async function loadReview() {
|
||||
@@ -251,7 +254,7 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
||||
<div><dt>申报箱数</dt><dd>{{ formatNumber(detail.originalPackageCount, ' 箱') }}</dd></div>
|
||||
<div><dt>实测箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</dd></div>
|
||||
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>称重 OCR</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>货物总重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
|
||||
<div><dt>设备实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
|
||||
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
|
||||
@@ -289,10 +292,13 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
||||
<div class="section-heading"><h3>称重图片识别结果</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
|
||||
<DataTable :data="detail.weightResults" :columns="weightColumns" empty-text="暂无称重图片识别结果" border stripe>
|
||||
<template #file="{ row }"><div class="table-wrap"><strong>{{ row.fileId || '未关联文件' }}</strong><span v-if="row.ocrRawText">OCR 原文:{{ row.ocrRawText }}</span></div></template>
|
||||
<template #pallet="{ row }"><div class="table-wrap"><strong>{{ row.palletCode }}</strong><span>{{ row.palletType }} · {{ formatNumber(row.palletCapacityCbm, ' m³') }}</span></div></template>
|
||||
<template #status="{ row }">{{ recognitionStatusLabel(row.recognitionStatus) }}</template>
|
||||
<template #ocrWeight="{ row }">{{ formatNumber(row.ocrWeightValue, ` ${row.ocrWeightUnit || ''}`) }}</template>
|
||||
<template #adjustedWeight="{ row }">{{ formatNumber(row.adjustedWeightValue, ` ${row.adjustedWeightUnit || ''}`) }}</template>
|
||||
<template #finalWeight="{ row }">{{ formatNumber(row.finalWeightKg, ' kg') }}</template>
|
||||
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg, ' kg') }}</template>
|
||||
<template #tareWeight="{ row }">{{ formatNumber(row.palletTareWeightKg, ' kg') }}</template>
|
||||
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg, ' kg') }}</template>
|
||||
<template #confidence="{ row }">{{ row.confidence === null ? '-' : `${Math.round(row.confidence * 100)}%` }}</template>
|
||||
<template #reason="{ row }">{{ row.adjustmentReason ? `调整原因:${row.adjustmentReason}` : '-' }}</template>
|
||||
</DataTable>
|
||||
|
||||
@@ -28,6 +28,11 @@ export interface MeasurementReviewItem {
|
||||
export interface MeasurementReviewWeightResult {
|
||||
id: string
|
||||
fileId: string | null
|
||||
palletId: string
|
||||
palletCode: string
|
||||
palletType: string
|
||||
palletCapacityCbm: number
|
||||
palletTareWeightKg: number
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
@@ -35,7 +40,8 @@ export interface MeasurementReviewWeightResult {
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
grossWeightKg: number | null
|
||||
netWeightKg: number | null
|
||||
recognitionStatus: string | null
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: string | null
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
downloadWeightImage,
|
||||
downloadWeightThumbnail,
|
||||
getWeightOcrRawText,
|
||||
getWeightPalletOptions,
|
||||
getWeightWorkspace,
|
||||
normalizeWeightConfidence,
|
||||
retryWeightRecognition,
|
||||
@@ -81,12 +82,24 @@ describe('inbound weight OCR API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('loads same-warehouse pallet snapshots through Firstleg', async () => {
|
||||
const options = [{ palletId: '11', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 }]
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: options })
|
||||
|
||||
await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual(options)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('registers, corrects, and confirms a weight result using the implemented contracts', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } })
|
||||
|
||||
const createPayload = {
|
||||
fileId: '9007199254740995',
|
||||
palletId: '11',
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: null,
|
||||
ocrWeightValue: 12.5,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CorrectWeightResultRequest,
|
||||
CreateWeightResultRequest,
|
||||
WeightImageUploadResult,
|
||||
WeightPalletOption,
|
||||
WeightResultResource,
|
||||
WeightWorkspaceResource,
|
||||
} from '../types'
|
||||
@@ -59,6 +60,14 @@ export async function getWeightWorkspace(tenantId: string, inboundId: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getWeightPalletOptions(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<WeightPalletOption[]>, ApiEnvelope<WeightPalletOption[]>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
downloadWeightImage,
|
||||
downloadWeightThumbnail,
|
||||
getWeightWorkspace,
|
||||
getWeightPalletOptions,
|
||||
uploadWeightImage,
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
@@ -30,6 +31,7 @@ vi.mock('../api/weightOcr', async () => {
|
||||
downloadWeightImage: vi.fn(),
|
||||
downloadWeightThumbnail: vi.fn(),
|
||||
getWeightWorkspace: vi.fn(),
|
||||
getWeightPalletOptions: vi.fn(),
|
||||
uploadWeightImage: vi.fn(),
|
||||
}
|
||||
})
|
||||
@@ -37,6 +39,11 @@ vi.mock('../api/weightOcr', async () => {
|
||||
const pendingWeight: WeightResultResource = {
|
||||
id: '30001',
|
||||
fileId: '40001',
|
||||
palletId: '50001',
|
||||
palletCode: 'TP-001',
|
||||
palletType: '木托盘',
|
||||
palletCapacityCbm: 1.2,
|
||||
palletTareWeightKg: 12,
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: '285.5',
|
||||
ocrWeightValue: 285.5,
|
||||
@@ -44,7 +51,8 @@ const pendingWeight: WeightResultResource = {
|
||||
confidence: 0.98,
|
||||
adjustedWeightValue: null,
|
||||
adjustedWeightUnit: null,
|
||||
finalWeightKg: null,
|
||||
grossWeightKg: 285.5,
|
||||
netWeightKg: null,
|
||||
recognitionStatus: 'PENDING_CONFIRM',
|
||||
adjustmentReason: null,
|
||||
adjustedBy: null,
|
||||
@@ -120,6 +128,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue(workspace)
|
||||
vi.mocked(getWeightPalletOptions).mockResolvedValue([{
|
||||
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
|
||||
}])
|
||||
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
|
||||
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
|
||||
vi.stubGlobal('URL', {
|
||||
@@ -129,7 +140,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
vi.mocked(confirmWeightResult).mockResolvedValue({
|
||||
...pendingWeight,
|
||||
recognitionStatus: 'CONFIRMED',
|
||||
finalWeightKg: 285.5,
|
||||
grossWeightKg: 297.5,
|
||||
netWeightKg: 285.5,
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
@@ -154,6 +166,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await wrapper.find('.pallet-picker .el-select').trigger('click')
|
||||
await new DOMWrapper(document.body).find('.el-select-dropdown__item').trigger('click')
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
const file = new File(['image'], 'weight.jpg', { type: 'image/jpeg' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
@@ -162,6 +176,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadWeightImage).toHaveBeenCalledWith('10001', '20001', file)
|
||||
expect(createWeightResult).toHaveBeenCalledWith('10001', '20001', expect.objectContaining({ palletId: '50001' }))
|
||||
expect(document.body.textContent).toContain('确认称重图片')
|
||||
expect(document.body.textContent).toContain('识别重量 12.500 KG')
|
||||
expect(document.body.querySelector('input[aria-label="当前图片确认重量"]')).not.toBeNull()
|
||||
@@ -225,7 +240,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
.mockResolvedValueOnce({
|
||||
...workspace,
|
||||
actualGrossWeightKg: 285.5,
|
||||
weightResults: [{ ...pendingWeight, recognitionStatus: 'CONFIRMED', finalWeightKg: 285.5, version: 1 }],
|
||||
weightResults: [{ ...pendingWeight, recognitionStatus: 'CONFIRMED', grossWeightKg: 297.5, netWeightKg: 285.5, version: 1 }],
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
@@ -236,10 +251,47 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
|
||||
expect(wrapper.emitted('summaryChanged')).toEqual([[285.5]])
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('已确认总毛重')
|
||||
expect(wrapper.text()).toContain('货物总重量')
|
||||
expect(wrapper.text()).toContain('285.500 KG')
|
||||
})
|
||||
|
||||
it('requires a pallet selection before opening the file picker', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const click = vi.spyOn(wrapper.get('input[type="file"]').element as HTMLInputElement, 'click')
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('上传称重图片'))?.trigger('click')
|
||||
|
||||
expect(click).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an actionable empty state when the warehouse has no pallet options', async () => {
|
||||
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce([])
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('当前仓库暂无可选托盘')
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('重新加载'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows pallet loading failures without hiding existing weight results', async () => {
|
||||
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503))
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('托盘服务暂不可用')
|
||||
expect(wrapper.findAll('.weight-card')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not load weight or pallet data without measurement view permission', async () => {
|
||||
const wrapper = mountPanel([])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('缺少入库测量查看权限')
|
||||
expect(getWeightWorkspace).not.toHaveBeenCalled()
|
||||
expect(getWeightPalletOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires explicit image review before confirming a low-confidence OCR value', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('WeightOcrPanel contract', () => {
|
||||
|
||||
it('covers upload, immediate preview, inline correction, deletion, confirmation, and server totals', () => {
|
||||
expect(source).toContain('uploadWeightImage')
|
||||
expect(source).toContain('getWeightPalletOptions')
|
||||
expect(source).toContain('palletId: selectedPalletId.value')
|
||||
expect(source).toContain('downloadWeightImage')
|
||||
expect(source).toContain('await openPreview(created, URL.createObjectURL(file))')
|
||||
expect(source).toContain('previewShowsRecognition.value = Boolean(localUrl)')
|
||||
@@ -26,7 +28,9 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(source).toContain('识别重量')
|
||||
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
|
||||
expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)')
|
||||
expect(source).toContain('已确认总毛重')
|
||||
expect(source).toContain('货物总重量')
|
||||
expect(source).toContain('palletTareWeightKg')
|
||||
expect(source).toContain('netWeightKg')
|
||||
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null)")
|
||||
})
|
||||
|
||||
@@ -53,6 +57,7 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(source).toContain('<LoadErrorState')
|
||||
expect(source).toContain('尚未上传称重图片')
|
||||
expect(source).toContain('v-loading="loading"')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source).toContain('@media (max-width: 360px)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ImagePlus,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
Trash2,
|
||||
} from '@lucide/vue'
|
||||
@@ -17,10 +18,11 @@ import {
|
||||
deleteWeightResult,
|
||||
downloadWeightImage,
|
||||
getWeightOcrRawText,
|
||||
getWeightPalletOptions,
|
||||
getWeightWorkspace,
|
||||
uploadWeightImage,
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
import type { WeightPalletOption, WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
||||
import { inboundProcessStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../../status'
|
||||
|
||||
@@ -33,6 +35,10 @@ const emit = defineEmits<{
|
||||
const access = useAccessPreviewStore()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const workspace = ref<WeightWorkspaceResource | null>(null)
|
||||
const palletOptions = ref<WeightPalletOption[]>([])
|
||||
const selectedPalletId = ref('')
|
||||
const palletLoading = ref(false)
|
||||
const palletError = ref<ApiErrorDetails | null>(null)
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -58,6 +64,7 @@ const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:c
|
||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
|
||||
const rows = computed(() => workspace.value?.weightResults ?? [])
|
||||
const selectedPallet = computed(() => palletOptions.value.find((option) => option.palletId === selectedPalletId.value) ?? null)
|
||||
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|
||||
|| previewSnapshot.value)
|
||||
const previewConfirmDisabled = computed(() => {
|
||||
@@ -124,7 +131,7 @@ function isWeightDraftValid(row: WeightResultResource) {
|
||||
}
|
||||
|
||||
function cardWeight(row: WeightResultResource) {
|
||||
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.finalWeightKg)
|
||||
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.netWeightKg)
|
||||
const draft = weightDrafts.value[row.id]
|
||||
return formatWeight(draft?.value ?? null, draft?.unit ?? 'KG')
|
||||
}
|
||||
@@ -176,11 +183,36 @@ async function loadWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPalletOptions() {
|
||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
||||
palletOptions.value = []
|
||||
selectedPalletId.value = ''
|
||||
return
|
||||
}
|
||||
palletLoading.value = true
|
||||
palletError.value = null
|
||||
try {
|
||||
const result = await getWeightPalletOptions(customerId.value, props.inboundId)
|
||||
palletOptions.value = result
|
||||
if (!result.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
|
||||
} catch (error) {
|
||||
palletOptions.value = []
|
||||
selectedPalletId.value = ''
|
||||
palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试')
|
||||
} finally {
|
||||
palletLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFile() {
|
||||
if (!editable.value) {
|
||||
ElMessage.warning('当前入库状态不允许上传称重图片')
|
||||
return
|
||||
}
|
||||
if (!selectedPalletId.value) {
|
||||
ElMessage.warning('请先选择本次称重使用的托盘')
|
||||
return
|
||||
}
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
@@ -206,6 +238,7 @@ async function handleFileChange(event: Event) {
|
||||
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
|
||||
const created = await createWeightResult(customerId.value, props.inboundId, {
|
||||
fileId: String(upload.fileId),
|
||||
palletId: selectedPalletId.value,
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: getWeightOcrRawText(upload),
|
||||
ocrWeightValue: upload.weightValue,
|
||||
@@ -295,7 +328,7 @@ async function removeResult(row: WeightResultResource) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
row.recognitionStatus === 'CONFIRMED'
|
||||
? '删除后将重新计算实际总毛重,确认删除这张称重图片吗?'
|
||||
? '删除后将重新计算货物总重量,确认删除这张称重图片吗?'
|
||||
: '确认删除这张称重图片吗?',
|
||||
'删除称重图片',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
@@ -324,12 +357,16 @@ watch(
|
||||
() => {
|
||||
++loadSequence
|
||||
workspace.value = null
|
||||
palletOptions.value = []
|
||||
selectedPalletId.value = ''
|
||||
palletError.value = null
|
||||
weightDrafts.value = {}
|
||||
loadError.value = null
|
||||
actionError.value = null
|
||||
previewOpen.value = false
|
||||
releasePreview()
|
||||
void loadWorkspace()
|
||||
void loadPalletOptions()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -350,13 +387,31 @@ defineExpose({ reload: loadWorkspace })
|
||||
<header class="weight-heading">
|
||||
<div>
|
||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
|
||||
<p>上传后直接核对重量,确认结果自动计入实际总毛重。</p>
|
||||
<p>先选择托盘,再上传图片核对含托盘毛重;皮重与货物净重由服务端计算。</p>
|
||||
</div>
|
||||
<div class="weight-heading__actions">
|
||||
<div v-if="workspace" class="weight-summary" aria-live="polite">
|
||||
<span>已确认总毛重</span>
|
||||
<span>货物总重量</span>
|
||||
<strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong>
|
||||
</div>
|
||||
<div class="pallet-picker">
|
||||
<el-select
|
||||
v-model="selectedPalletId"
|
||||
aria-label="选择称重托盘"
|
||||
placeholder="选择称重托盘"
|
||||
filterable
|
||||
:loading="palletLoading"
|
||||
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in palletOptions"
|
||||
:key="option.palletId"
|
||||
:label="`${option.palletCode} · ${option.palletType}`"
|
||||
:value="option.palletId"
|
||||
/>
|
||||
</el-select>
|
||||
<small v-if="selectedPallet">皮重 {{ formatWeight(selectedPallet.tareWeightKg) }} · 容量 {{ selectedPallet.capacityCbm }} m³</small>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden-input"
|
||||
@@ -369,7 +424,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
v-if="canEdit"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="loading || !editable"
|
||||
:disabled="loading || palletLoading || !editable || !selectedPalletId || Boolean(palletError)"
|
||||
@click="chooseFile"
|
||||
><ImagePlus :size="16" />上传称重图片</el-button>
|
||||
</div>
|
||||
@@ -392,6 +447,27 @@ defineExpose({ reload: loadWorkspace })
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else>
|
||||
<LoadErrorState
|
||||
v-if="palletError"
|
||||
title="可选托盘加载失败"
|
||||
:message="palletError.message"
|
||||
:loading="palletLoading"
|
||||
compact
|
||||
class="weight-action-error"
|
||||
@retry="loadPalletOptions"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="palletError" /></template>
|
||||
</LoadErrorState>
|
||||
<el-alert
|
||||
v-else-if="!palletLoading && palletOptions.length === 0 && editable && canEdit"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="weight-action-error"
|
||||
title="当前仓库暂无可选托盘,暂时不能上传称重图片。"
|
||||
>
|
||||
<el-button size="small" @click="loadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
|
||||
当前入库状态为 {{ inboundStatusLabel(workspace.processStatus, inboundProcessStatusLabels, '未知状态') }},称重数据仅供查看。
|
||||
</el-alert>
|
||||
@@ -440,6 +516,12 @@ defineExpose({ reload: loadWorkspace })
|
||||
:aria-label="`查看称重图片 ${index + 1},重量 ${cardWeight(row)}`"
|
||||
@click="openPreview(row)"
|
||||
><strong>{{ cardWeight(row) }}</strong></button>
|
||||
<dl class="weight-card__facts">
|
||||
<div><dt>托盘</dt><dd>{{ row.palletCode }}</dd></div>
|
||||
<div><dt>含托盘毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
|
||||
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
|
||||
<div><dt>货物净重</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -471,8 +553,14 @@ defineExpose({ reload: loadWorkspace })
|
||||
<template v-else-if="previewResult">
|
||||
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
||||
<dl class="ocr-evidence" aria-label="OCR 识别证据">
|
||||
<div><dt>托盘编码</dt><dd>{{ previewResult.palletCode }}</dd></div>
|
||||
<div><dt>托盘类型</dt><dd>{{ previewResult.palletType }}</dd></div>
|
||||
<div><dt>托盘皮重</dt><dd>{{ formatWeight(previewResult.palletTareWeightKg) }}</dd></div>
|
||||
<div><dt>托盘容量</dt><dd>{{ previewResult.palletCapacityCbm }} m³</dd></div>
|
||||
<div><dt>识别状态</dt><dd>{{ recognitionLabel(previewResult.recognitionStatus) }}</dd></div>
|
||||
<div><dt>置信度</dt><dd>{{ formatConfidence(previewResult.confidence) }}</dd></div>
|
||||
<div><dt>服务端毛重</dt><dd>{{ formatWeight(previewResult.grossWeightKg) }}</dd></div>
|
||||
<div><dt>服务端净重</dt><dd>{{ formatWeight(previewResult.netWeightKg) }}</dd></div>
|
||||
<div class="ocr-evidence__raw"><dt>OCR 原文</dt><dd>{{ previewResult.ocrRawText || '未识别到有效重量' }}</dd></div>
|
||||
<div v-if="previewResult.adjustmentReason" class="ocr-evidence__raw">
|
||||
<dt>人工修正</dt><dd>{{ previewResult.adjustmentReason }}</dd>
|
||||
@@ -487,14 +575,14 @@ defineExpose({ reload: loadWorkspace })
|
||||
title="OCR 可能误读数字或小数点,确认前请对照原图核对。"
|
||||
/>
|
||||
<div v-if="previewResult.recognitionStatus === 'CONFIRMED'" class="preview-weight is-confirmed">
|
||||
<span>确认重量</span><strong>{{ formatWeight(previewResult.finalWeightKg) }}</strong>
|
||||
<span>货物净重</span><strong>{{ formatWeight(previewResult.netWeightKg) }}</strong>
|
||||
</div>
|
||||
<div v-else-if="weightDrafts[previewResult.id]" class="preview-weight">
|
||||
<span v-if="previewShowsRecognition" class="preview-weight__recognized">
|
||||
识别重量 {{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || '') }}
|
||||
</span>
|
||||
<label>
|
||||
<span>确认重量</span>
|
||||
<span>确认含托盘毛重</span>
|
||||
<el-input-number
|
||||
v-model="weightDrafts[previewResult.id].value"
|
||||
aria-label="当前图片确认重量"
|
||||
@@ -530,6 +618,8 @@ defineExpose({ reload: loadWorkspace })
|
||||
.weight-heading h3 svg { color: var(--cc-primary); }
|
||||
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 16px; }
|
||||
.pallet-picker { display: grid; min-width: 260px; gap: 4px; }
|
||||
.pallet-picker small { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
|
||||
.weight-summary { display: grid; gap: 2px; text-align: right; }
|
||||
.weight-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
.weight-summary strong { color: #067647; font-size: 18px; }
|
||||
@@ -546,6 +636,10 @@ defineExpose({ reload: loadWorkspace })
|
||||
.weight-card__value { width: 100%; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: center; }
|
||||
.weight-card__value strong { color: #101828; font-size: 17px; }
|
||||
.weight-card__value:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 4px; }
|
||||
.weight-card__facts { display: grid; gap: 6px; margin: 10px 0 0; padding-top: 10px; border-top: 1px solid var(--cc-border); }
|
||||
.weight-card__facts div { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; line-height: 1.4; }
|
||||
.weight-card__facts dt { color: var(--cc-muted); }
|
||||
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
|
||||
.weight-preview { display: block; width: 100%; max-height: min(58vh, 560px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
|
||||
.ocr-evidence { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 18px; margin: 14px 0 0; padding: 12px 0; border-block: 1px solid var(--cc-border); }
|
||||
.ocr-evidence div { min-width: 0; }
|
||||
@@ -566,6 +660,8 @@ defineExpose({ reload: loadWorkspace })
|
||||
@media (max-width: 720px) {
|
||||
.weight-heading { flex-direction: column; }
|
||||
.weight-heading__actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||
.pallet-picker { width: 100%; min-width: 0; }
|
||||
.pallet-picker :deep(.el-select) { width: 100%; }
|
||||
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.weight-summary { text-align: left; }
|
||||
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -577,5 +673,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
.preview-weight__recognized { grid-column: 1; }
|
||||
.ocr-evidence { grid-template-columns: 1fr; }
|
||||
.ocr-evidence__raw { grid-column: 1; }
|
||||
.weight-card__facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
|
||||
.weight-card__facts dd { text-align: left; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,11 @@ export type WeightRecognitionStatus = 'PENDING_CONFIRM' | 'NEEDS_MANUAL' | 'CONF
|
||||
export interface WeightResultResource {
|
||||
id: string
|
||||
fileId: string
|
||||
palletId: string
|
||||
palletCode: string
|
||||
palletType: string
|
||||
palletCapacityCbm: number
|
||||
palletTareWeightKg: number
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
@@ -10,7 +15,8 @@ export interface WeightResultResource {
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
grossWeightKg: number | null
|
||||
netWeightKg: number | null
|
||||
recognitionStatus: WeightRecognitionStatus
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: string | null
|
||||
@@ -28,6 +34,14 @@ export interface WeightWorkspaceResource {
|
||||
weightResults: WeightResultResource[]
|
||||
}
|
||||
|
||||
export interface WeightPalletOption {
|
||||
palletId: string
|
||||
palletCode: string
|
||||
palletType: string
|
||||
capacityCbm: number
|
||||
tareWeightKg: number
|
||||
}
|
||||
|
||||
export interface WeightImageUploadResult {
|
||||
fileId: string
|
||||
fileName: string
|
||||
@@ -44,6 +58,7 @@ export interface WeightImageUploadResult {
|
||||
|
||||
export interface CreateWeightResultRequest {
|
||||
fileId: string
|
||||
palletId: string
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
|
||||
Reference in New Issue
Block a user