This commit is contained in:
2026-08-12 14:08:33 +08:00
parent 4fb8b85c13
commit 975e0057e3
20 changed files with 378 additions and 29 deletions
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import {
downloadInboundMeasurementImage,
createManualCartonScan,
getInboundMeasurement,
submitInboundMeasurement,
updateConfirmedPackageCount,
@@ -15,6 +16,7 @@ import type { InboundMeasurementDetail } from './types'
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
vi.mock('./api', () => ({
createManualCartonScan: vi.fn(),
downloadInboundMeasurementImage: vi.fn(),
getInboundMeasurement: vi.fn(),
saveInboundMeasurementDraft: vi.fn(),
@@ -133,6 +135,10 @@ describe('InboundMeasurementPanel', () => {
measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING',
recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null,
})
vi.mocked(createManualCartonScan).mockReset().mockResolvedValue({
measurementId: '9001', measurementVersion: 3, itemId: '9102', boxCode: 'RK202608040001#10-2',
confirmedPackageCount: 1,
})
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
})
@@ -151,6 +157,19 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.text()).toContain('预览图片')
})
it('opens the manual carton entry form in an editable measurement state', async () => {
const wrapper = await mountPanel()
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes('手动录入箱规'))
expect(button?.attributes('disabled')).toBeUndefined()
await button?.trigger('click')
expect(wrapper.text()).toContain('手动录入箱规')
expect(document.body.textContent).toContain('箱号')
expect(document.body.textContent).toContain('测量长(cm)')
expect(document.body.textContent).toContain('测量图片')
expect(document.body.textContent).toContain('保存箱规')
})
it('does not expose unknown measurement source codes', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ sourceType: 'LEGACY_DEVICE' }))
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AlertTriangle, Image, RefreshCw, Ruler, Save, Send } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { AlertTriangle, Image, Plus, RefreshCw, Ruler, Save, Send, Upload } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -9,7 +9,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
import { downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import type {
InboundMeasurementDetail,
InboundMeasurementItem,
@@ -42,10 +42,25 @@ interface MeasurementFormModel {
items: EditableMeasurementItem[]
}
interface ManualCartonFormModel {
boxCode: string
length: number | null
width: number | null
height: number | null
weight: number | null
scannedShipmentId: string
machineNo: string
measuredAt: string
}
const access = useAccessPreviewStore()
const loading = ref(false)
const saving = ref(false)
const submitting = ref(false)
const manualEntryOpen = ref(false)
const manualEntrySubmitting = ref(false)
const manualEntryImage = ref<File | null>(null)
const manualEntryErrors = ref<string[]>([])
const detail = ref<InboundMeasurementDetail | null>(null)
const loadError = ref<ApiErrorDetails | null>(null)
const operationError = ref<ApiErrorDetails | null>(null)
@@ -69,6 +84,17 @@ const form = reactive<MeasurementFormModel>({
items: [],
})
const manualForm = reactive<ManualCartonFormModel>({
boxCode: '',
length: null,
width: null,
height: null,
weight: null,
scannedShipmentId: '',
machineNo: 'MANUAL-ADMIN',
measuredAt: '',
})
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
@@ -77,6 +103,8 @@ const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const countEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const submitEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayMaintain = computed(() => canEdit.value && countEditableProcess.value && !loading.value && !submitting.value)
const manualEntryEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayCreateManualEntry = computed(() => canEdit.value && manualEntryEditableProcess.value && !loading.value && !saving.value && !submitting.value && !isDirty.value)
const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => sum + (Number.isFinite(item.packageCount) ? item.packageCount : 0), 0))
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
@@ -108,6 +136,81 @@ function nextItemKey() {
return `measurement-item-${itemSequence}`
}
function localDateTimeNow() {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
}
function resetManualEntry() {
manualForm.boxCode = ''
manualForm.length = null
manualForm.width = null
manualForm.height = null
manualForm.weight = null
manualForm.scannedShipmentId = ''
manualForm.machineNo = 'MANUAL-ADMIN'
manualForm.measuredAt = localDateTimeNow()
manualEntryImage.value = null
manualEntryErrors.value = []
}
function openManualEntry() {
if (!mayCreateManualEntry.value) {
if (isDirty.value) ElMessage.warning('请先保存当前确认箱数,再录入新的箱规')
return
}
resetManualEntry()
manualEntryOpen.value = true
}
function selectManualImage(uploadFile: UploadFile) {
const file = uploadFile.raw
manualEntryImage.value = file || null
}
function validateManualEntry() {
const errors: string[] = []
if (!manualForm.boxCode.trim()) errors.push('请输入箱号')
if (manualForm.boxCode.trim().length > 128) errors.push('箱号不能超过 128 个字符')
if (!(manualForm.length != null && manualForm.length > 0)) errors.push('测量长必须大于 0')
if (!(manualForm.width != null && manualForm.width > 0)) errors.push('测量宽必须大于 0')
if (!(manualForm.height != null && manualForm.height > 0)) errors.push('测量高必须大于 0')
if (!(manualForm.weight != null && manualForm.weight > 0)) errors.push('单箱毛重必须大于 0')
if (!manualForm.measuredAt) errors.push('请选择测量时间')
if (!manualEntryImage.value) errors.push('请上传当前箱测量图片')
if (manualEntryImage.value && manualEntryImage.value.size > 5 * 1024 * 1024) errors.push('测量图片不能超过 5 MB')
manualEntryErrors.value = errors
return errors.length === 0
}
async function submitManualEntry() {
if (!mayCreateManualEntry.value || !validateManualEntry() || manualForm.weight == null) return
manualEntrySubmitting.value = true
operationError.value = null
try {
await createManualCartonScan(customerId.value, props.inboundId, {
boxCode: manualForm.boxCode.trim(),
length: manualForm.length!,
width: manualForm.width!,
height: manualForm.height!,
weight: manualForm.weight,
scannedShipmentId: manualForm.scannedShipmentId.trim() || null,
machineNo: manualForm.machineNo.trim() || 'MANUAL-ADMIN',
measuredAt: manualForm.measuredAt,
}, manualEntryImage.value!)
manualEntryOpen.value = false
ElMessage.success('箱规已录入,服务端计算结果已更新')
await loadMeasurement()
emit('changed')
} catch (error) {
const details = setOperationError(error, '手动箱规录入失败,请检查后重试')
ElMessage.error(details.message)
} finally {
manualEntrySubmitting.value = false
}
}
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
return {
itemId: String(item.id),
@@ -453,6 +556,7 @@ onBeforeUnmount(releaseImagePreview)
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>扫描箱测量数据明细</h3><p>每条记录对应一个实际扫描箱;人工只确认该条记录代表的箱数,设备实测值不可在此修改。</p></div>
<el-button v-if="canEdit" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
</div>
<el-empty v-if="!form.items.length" description="暂无扫描箱测量数据" />
@@ -503,6 +607,34 @@ onBeforeUnmount(releaseImagePreview)
<el-empty v-else description="暂无可预览图片" />
</div>
</el-dialog>
<el-dialog v-model="manualEntryOpen" title="手动录入箱规" width="min(680px, 92vw)" destroy-on-close>
<el-alert v-if="manualEntryErrors.length" type="error" :closable="false" show-icon class="manual-entry-alert" title="请修正以下问题">
<ul><li v-for="error in manualEntryErrors" :key="error">{{ error }}</li></ul>
</el-alert>
<el-form label-position="top" class="manual-entry-form" @submit.prevent="submitManualEntry">
<div class="measurement-form-grid">
<el-form-item label="箱号 *"><el-input v-model="manualForm.boxCode" placeholder="请输入已打印箱唛上的箱号" /></el-form-item>
<el-form-item label="测量长(cm) *"><el-input-number v-model="manualForm.length" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="测量宽(cm) *"><el-input-number v-model="manualForm.width" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="测量高(cm) *"><el-input-number v-model="manualForm.height" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="单箱毛重(kg) *"><el-input-number v-model="manualForm.weight" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="测量时间 *"><el-date-picker v-model="manualForm.measuredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
<el-form-item label="设备/来源编号"><el-input v-model="manualForm.machineNo" maxlength="64" /></el-form-item>
<el-form-item label="Shipment ID"><el-input v-model="manualForm.scannedShipmentId" maxlength="64" /></el-form-item>
</div>
<el-form-item label="当前箱测量图片 *">
<el-upload class="manual-entry-upload" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectManualImage" :on-remove="() => { manualEntryImage = null }">
<el-button><Upload :size="15" />选择图片</el-button>
<template #tip><div class="el-upload__tip">支持图片格式,大小不超过 5 MB</div></template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="manualEntrySubmitting" @click="manualEntryOpen = false">取消</el-button>
<el-button type="primary" :loading="manualEntrySubmitting" @click="submitManualEntry">保存箱规</el-button>
</template>
</el-dialog>
</template>
</section>
</template>
@@ -545,6 +677,10 @@ onBeforeUnmount(releaseImagePreview)
.measurement-image-field :deep(.el-button) { width: 100%; margin: 0; }
.measurement-image-preview { display: grid; min-height: 240px; place-items: center; }
.measurement-image-preview img { display: block; width: auto; max-width: 100%; max-height: 68vh; object-fit: contain; }
.manual-entry-alert { margin-bottom: 16px; }
.manual-entry-alert ul { margin: 6px 0 0; padding-left: 18px; }
.manual-entry-form :deep(.el-input-number), .manual-entry-form :deep(.el-date-editor) { width: 100%; }
.manual-entry-upload { width: 100%; }
.measurement-items-section { background: #fafbfc; }
.measurement-item { overflow: hidden; margin-top: 14px; border: 1px solid var(--cc-border); border-radius: 9px; background: #fff; }
.measurement-item.has-error { border-color: var(--el-color-danger-light-5); }
+17 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import type { SaveInboundMeasurementDraftRequest } from './types'
vi.mock('@/shared/api/http', () => ({
@@ -101,4 +101,20 @@ describe('inbound measurement API', () => {
{ params: { tenantId: '10001' } },
)
})
it('posts manual carton scan data and image to the scoped multipart route', async () => {
const result = { measurementId: '9001', measurementVersion: 3, itemId: '2', boxCode: 'RK#10-2', confirmedPackageCount: 1 }
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
const image = new File(['image'], 'carton.jpg', { type: 'image/jpeg' })
await expect(createManualCartonScan('10001', '7001', {
boxCode: 'RK#10-2', length: 40, width: 30, height: 20, weight: 5,
machineNo: 'MANUAL-ADMIN', measuredAt: '2026-08-12T14:00:00', scannedShipmentId: null,
}, image)).resolves.toEqual(result)
const [path, body, config] = vi.mocked(http.post).mock.calls[0]!
expect(path).toBe('/platform/v1/first-leg/inbounds/7001/measurement/carton-scans')
expect(body).toBeInstanceOf(FormData)
expect((body as FormData).get('image')).toBe(image)
expect(config).toEqual({ params: { tenantId: '10001' }, headers: { 'Content-Type': 'multipart/form-data' } })
})
})
+18
View File
@@ -7,6 +7,7 @@ import type {
ConfirmedPackageCountRequest,
SaveInboundMeasurementDraftRequest,
SubmitInboundMeasurementRequest,
ManualCartonScanRequest,
} from './types'
function measurementPath(inboundId: string) {
@@ -67,6 +68,23 @@ export async function submitInboundMeasurement(
return unwrapApiResponse(response)
}
export async function createManualCartonScan(
customerId: string,
inboundId: string,
payload: ManualCartonScanRequest,
image: File,
) {
const data = new FormData()
data.append('data', new Blob([JSON.stringify(payload)], { type: 'application/json' }))
data.append('image', image)
const response = await http.post<ApiEnvelope<CartonMeasurementResult>, ApiEnvelope<CartonMeasurementResult>>(
`${measurementPath(inboundId)}/carton-scans`,
data,
{ params: { tenantId: customerId }, headers: { 'Content-Type': 'multipart/form-data' } },
)
return unwrapApiResponse(response)
}
export function downloadInboundMeasurementImage(customerId: string, inboundId: string, fileId: string) {
return http.get<Blob, Blob>(
`/platform/v1/files/measurements/${segment(customerId)}/${segment(inboundId)}/${segment(fileId)}`,
+11
View File
@@ -133,6 +133,17 @@ export interface CartonMeasurementResult {
confirmedPackageCount: number
}
export interface ManualCartonScanRequest {
boxCode: string
length: number
width: number
height: number
weight: number | null
machineNo: string
measuredAt: string
scannedShipmentId: string | null
}
export interface SubmitInboundMeasurementRequest {
measurementId: MeasurementIdentifier
version: number