优化
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import {
|
||||
getInboundMeasurement,
|
||||
saveInboundMeasurementDraft,
|
||||
submitInboundMeasurement,
|
||||
} from './api'
|
||||
import type { InboundMeasurementDetail } from './types'
|
||||
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
getInboundMeasurement: vi.fn(),
|
||||
saveInboundMeasurementDraft: vi.fn(),
|
||||
submitInboundMeasurement: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
function measurement(overrides: Partial<InboundMeasurementDetail> = {}): InboundMeasurementDetail {
|
||||
return {
|
||||
inboundId: '7001',
|
||||
inboundCode: 'RK202608040001',
|
||||
companyName: '测试企业',
|
||||
channelCode: 'SEA-FBA',
|
||||
channelName: '美西海运',
|
||||
processStatus: 'MEASURING',
|
||||
measureReviewStatus: null,
|
||||
recommendedPalletType: 'STANDARD',
|
||||
reviewReason: null,
|
||||
reviewerId: null,
|
||||
reviewerName: null,
|
||||
measurementId: '9001',
|
||||
sourceType: 'THIRD_PARTY',
|
||||
measurementStatus: 'RECEIVED',
|
||||
version: 2,
|
||||
originalDocumentNo: 'DOC-001',
|
||||
originalGrossWeightKg: 118.5,
|
||||
originalVolumeCbm: 0.8,
|
||||
actualPackageCount: 10,
|
||||
actualGrossWeightKg: 120,
|
||||
actualVolumeCbm: 0.6,
|
||||
volumeWeightKg: 100.2,
|
||||
actualGoodsName: '蓝牙音箱',
|
||||
items: [{
|
||||
id: '9101',
|
||||
itemNo: '001',
|
||||
boxSpecification: '50×40×30cm',
|
||||
packageCount: 10,
|
||||
lengthCm: 50,
|
||||
widthCm: 40,
|
||||
heightCm: 30,
|
||||
girthCm: 190,
|
||||
unitWeightKg: 12,
|
||||
unitVolumeCbm: 0.06,
|
||||
totalWeightKg: 120,
|
||||
totalVolumeCbm: 0.6,
|
||||
volumeWeightKg: 100.2,
|
||||
intelligentImageFileId: '88001',
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: '设备自动回传',
|
||||
}],
|
||||
weightResults: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountPanel(permissions = [
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'platform:firstleg:inbound:measure:edit',
|
||||
'platform:firstleg:inbound:measure:submit',
|
||||
]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(InboundMeasurementPanel, {
|
||||
props: { customerId: '10001', inboundId: '7001' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('InboundMeasurementPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(getInboundMeasurement).mockReset().mockResolvedValue(measurement())
|
||||
vi.mocked(saveInboundMeasurementDraft).mockReset().mockResolvedValue({ measurementId: '9001', version: 3, status: 'DRAFT' })
|
||||
vi.mocked(submitInboundMeasurement).mockReset().mockResolvedValue({
|
||||
measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING',
|
||||
recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null,
|
||||
})
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
})
|
||||
|
||||
it('loads third-party facts and renders server-calculated values separately', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001')
|
||||
expect(wrapper.text()).toContain('第三方设备')
|
||||
expect(wrapper.text()).toContain('RK202608040001')
|
||||
expect(wrapper.text()).toContain('箱规周长')
|
||||
expect(wrapper.text()).toContain('190 cm')
|
||||
expect(wrapper.text()).toContain('0.6 CBM')
|
||||
expect((wrapper.find('input[placeholder="请输入本箱规的补充说明"]').element as HTMLInputElement).value).toBe('设备自动回传')
|
||||
})
|
||||
|
||||
it('follows the prototype grouping, labels, and editable field order', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const text = wrapper.text()
|
||||
|
||||
expect(text).toContain('原单数据')
|
||||
expect(text).toContain('入库单测量数据明细')
|
||||
expect(text).toContain('实测总箱数')
|
||||
expect(text).toContain('实测总毛重')
|
||||
expect(text).toContain('实测总体积')
|
||||
expect(text).toContain('实测总材积重')
|
||||
expect(text).not.toContain('租户')
|
||||
|
||||
const labels = wrapper.findAll('.measurement-item__grid label > span').map((label) => label.text())
|
||||
expect(labels.slice(0, 8)).toEqual([
|
||||
'明细编号 *',
|
||||
'箱规 *',
|
||||
'箱规长(cm)*',
|
||||
'箱规宽(cm)*',
|
||||
'箱规高(cm)*',
|
||||
'箱数 *',
|
||||
'每箱毛重(kg)*',
|
||||
'智能图片文件 ID',
|
||||
])
|
||||
})
|
||||
|
||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(wrapper.classes()).toContain('surface-panel')
|
||||
expect(wrapper.find('.measurement-panel__heading').classes()).toContain('panel-heading')
|
||||
expect(wrapper.find('.status-badge').text()).toBe('测量中')
|
||||
})
|
||||
|
||||
it('saves only editable raw fields with the current measurement version', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('智能音箱')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledOnce()
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
|
||||
measurementId: '9001',
|
||||
version: 2,
|
||||
actualGoodsName: '智能音箱',
|
||||
items: [expect.objectContaining({
|
||||
itemNo: '001', packageCount: 10, lengthCm: 50, intelligentImageFileId: '88001',
|
||||
})],
|
||||
}))
|
||||
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0][2]
|
||||
expect(payload.items[0]).not.toHaveProperty('totalVolumeCbm')
|
||||
})
|
||||
|
||||
it('preserves the edited form when the backend reports a version conflict', async () => {
|
||||
vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce(
|
||||
new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 200, null, 'req-conflict', 'trace-conflict'),
|
||||
)
|
||||
const wrapper = await mountPanel()
|
||||
const goodsInput = wrapper.find('input[placeholder="请输入实际货物名称"]')
|
||||
await goodsInput.setValue('保留我的修改')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('数据版本已变化,当前修改尚未丢失')
|
||||
expect(wrapper.text()).toContain('req-conflict')
|
||||
expect((goodsInput.element as HTMLInputElement).value).toBe('保留我的修改')
|
||||
expect(getInboundMeasurement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requires the dirty draft to be saved before submitting for review', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('尚未保存')
|
||||
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
|
||||
await submit?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(submitInboundMeasurement).not.toHaveBeenCalled()
|
||||
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits the saved version and emits changed after confirmation', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
|
||||
await submit?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('准备托盘与库位占用'),
|
||||
'提交测量审核',
|
||||
expect.objectContaining({ confirmButtonText: '确认提交' }),
|
||||
)
|
||||
expect(submitInboundMeasurement).toHaveBeenCalledWith('10001', '7001', { measurementId: '9001', version: 2 })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears the previous customer context before loading the next one', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ inboundId: '7002', inboundCode: 'RK202608040002', companyName: '第二企业' }))
|
||||
|
||||
await wrapper.setProps({ customerId: '10002', inboundId: '7002' })
|
||||
await flushPromises()
|
||||
|
||||
expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002')
|
||||
expect(wrapper.text()).toContain('RK202608040002')
|
||||
expect(wrapper.text()).not.toContain('测试企业')
|
||||
})
|
||||
|
||||
it('renders a permission-specific read-only state without requesting data', async () => {
|
||||
const wrapper = await mountPanel([])
|
||||
|
||||
expect(wrapper.text()).toContain('无测量查看权限')
|
||||
expect(getInboundMeasurement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,635 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { AlertTriangle, Plus, RefreshCw, Ruler, Save, Send, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api'
|
||||
import type {
|
||||
InboundMeasurementDetail,
|
||||
InboundMeasurementItem,
|
||||
SaveInboundMeasurementDraftRequest,
|
||||
SaveInboundMeasurementItemRequest,
|
||||
} from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
|
||||
clientKey: string
|
||||
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
|
||||
}
|
||||
|
||||
interface MeasurementFormModel {
|
||||
measurementId: string | null
|
||||
version: number
|
||||
originalDocumentNo: string
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualGoodsName: string
|
||||
items: EditableMeasurementItem[]
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detail = ref<InboundMeasurementDetail | null>(null)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const operationError = ref<ApiErrorDetails | null>(null)
|
||||
const conflictDetected = ref(false)
|
||||
const validationErrors = ref<Record<string, string[]>>({})
|
||||
const baseline = ref('')
|
||||
let requestSequence = 0
|
||||
let itemSequence = 0
|
||||
|
||||
const form = reactive<MeasurementFormModel>({
|
||||
measurementId: null,
|
||||
version: 0,
|
||||
originalDocumentNo: '',
|
||||
originalGrossWeightKg: null,
|
||||
originalVolumeCbm: null,
|
||||
actualGoodsName: '',
|
||||
items: [],
|
||||
})
|
||||
|
||||
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'))
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
|
||||
const editableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
||||
const mayMaintain = computed(() => canEdit.value && editableProcess.value && !loading.value && !submitting.value)
|
||||
const maySubmit = computed(() => canSubmit.value && editableProcess.value && Boolean(form.measurementId) && !loading.value && !saving.value)
|
||||
const snapshot = computed(() => JSON.stringify(form))
|
||||
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
|
||||
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
|
||||
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
|
||||
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || detail.value?.sourceType || '尚未测量'))
|
||||
const sourceTagType = computed(() => detail.value?.sourceType === 'MANUAL' ? 'info' : 'primary')
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'p2-inbound-measurement-editor',
|
||||
label: computed(() => `入库单 ${detail.value?.inboundCode || props.inboundId} 的测量草稿`),
|
||||
isOpen: registrationOpen,
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
|
||||
function optionalText(value: string) {
|
||||
const normalized = value.trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function nextItemKey() {
|
||||
itemSequence += 1
|
||||
return `measurement-item-${itemSequence}`
|
||||
}
|
||||
|
||||
function emptyItem(): EditableMeasurementItem {
|
||||
return {
|
||||
clientKey: nextItemKey(),
|
||||
itemNo: '',
|
||||
boxSpecification: '',
|
||||
packageCount: 1,
|
||||
lengthCm: 0,
|
||||
widthCm: 0,
|
||||
heightCm: 0,
|
||||
unitWeightKg: 0,
|
||||
intelligentImageFileId: null,
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: null,
|
||||
serverValues: { girthCm: null, unitVolumeCbm: null, totalWeightKg: null, totalVolumeCbm: null, volumeWeightKg: null },
|
||||
}
|
||||
}
|
||||
|
||||
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
||||
return {
|
||||
clientKey: nextItemKey(),
|
||||
itemNo: item.itemNo,
|
||||
boxSpecification: item.boxSpecification,
|
||||
packageCount: item.packageCount,
|
||||
lengthCm: item.lengthCm,
|
||||
widthCm: item.widthCm,
|
||||
heightCm: item.heightCm,
|
||||
unitWeightKg: item.unitWeightKg,
|
||||
intelligentImageFileId: item.intelligentImageFileId == null ? null : String(item.intelligentImageFileId),
|
||||
overLimitWarning: item.overLimitWarning,
|
||||
outerLengthCm: item.outerLengthCm,
|
||||
outerWidthCm: item.outerWidthCm,
|
||||
outerHeightCm: item.outerHeightCm,
|
||||
remark: item.remark,
|
||||
serverValues: {
|
||||
girthCm: item.girthCm,
|
||||
unitVolumeCbm: item.unitVolumeCbm,
|
||||
totalWeightKg: item.totalWeightKg,
|
||||
totalVolumeCbm: item.totalVolumeCbm,
|
||||
volumeWeightKg: item.volumeWeightKg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
form.measurementId = null
|
||||
form.version = 0
|
||||
form.originalDocumentNo = ''
|
||||
form.originalGrossWeightKg = null
|
||||
form.originalVolumeCbm = null
|
||||
form.actualGoodsName = ''
|
||||
form.items.splice(0)
|
||||
baseline.value = JSON.stringify(form)
|
||||
captureBaseline()
|
||||
}
|
||||
|
||||
function applyDetail(resource: InboundMeasurementDetail) {
|
||||
detail.value = resource
|
||||
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
|
||||
form.version = resource.version ?? 0
|
||||
form.originalDocumentNo = resource.originalDocumentNo || ''
|
||||
form.originalGrossWeightKg = resource.originalGrossWeightKg
|
||||
form.originalVolumeCbm = resource.originalVolumeCbm
|
||||
form.actualGoodsName = resource.actualGoodsName || ''
|
||||
form.items.splice(0, form.items.length, ...resource.items.map(editableItem))
|
||||
baseline.value = JSON.stringify(form)
|
||||
captureBaseline()
|
||||
validationErrors.value = {}
|
||||
conflictDetected.value = false
|
||||
}
|
||||
|
||||
async function loadMeasurement() {
|
||||
if (!hasContext.value || !canView.value) return
|
||||
const sequence = ++requestSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
operationError.value = null
|
||||
try {
|
||||
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
|
||||
if (sequence !== requestSequence) return
|
||||
applyDetail(resource)
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence) return
|
||||
detail.value = null
|
||||
clearForm()
|
||||
loadError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadLatest() {
|
||||
if (isDirty.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('加载最新版本会丢弃当前尚未保存的修改。确定继续吗?', '重新加载测量数据', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '加载最新版本',
|
||||
cancelButtonText: '继续编辑',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
await loadMeasurement()
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (form.items.length >= 500) {
|
||||
ElMessage.warning('单个测量批次最多包含 500 条箱规')
|
||||
return
|
||||
}
|
||||
form.items.push(emptyItem())
|
||||
}
|
||||
|
||||
async function removeItem(index: number) {
|
||||
if (saving.value || submitting.value) return
|
||||
if (form.items.length === 1) {
|
||||
ElMessage.warning('测量草稿至少需要一条箱规')
|
||||
return
|
||||
}
|
||||
form.items.splice(index, 1)
|
||||
}
|
||||
|
||||
function isPositive(value: number | null) {
|
||||
return value != null && Number.isFinite(value) && value > 0
|
||||
}
|
||||
|
||||
function isNonNegative(value: number | null) {
|
||||
return value == null || (Number.isFinite(value) && value >= 0)
|
||||
}
|
||||
|
||||
function validateDraft() {
|
||||
const errors: Record<string, string[]> = {}
|
||||
const general: string[] = []
|
||||
if (!form.items.length) general.push('请至少添加一条箱规')
|
||||
if (form.items.length > 500) general.push('单个测量批次最多包含 500 条箱规')
|
||||
if (form.originalDocumentNo.trim().length > 64) general.push('原始单据号不能超过 64 个字符')
|
||||
if (form.actualGoodsName.trim().length > 255) general.push('实际品名不能超过 255 个字符')
|
||||
if (!isNonNegative(form.originalGrossWeightKg)) general.push('申报毛重不能小于 0')
|
||||
if (!isNonNegative(form.originalVolumeCbm)) general.push('申报体积不能小于 0')
|
||||
form.items.forEach((item, index) => {
|
||||
const itemErrors: string[] = []
|
||||
if (!item.itemNo.trim()) itemErrors.push('请输入箱规序号')
|
||||
else if (item.itemNo.trim().length > 32) itemErrors.push('箱规序号不能超过 32 个字符')
|
||||
if (!item.boxSpecification.trim()) itemErrors.push('请输入箱规描述')
|
||||
else if (item.boxSpecification.trim().length > 64) itemErrors.push('箱规描述不能超过 64 个字符')
|
||||
if (!Number.isInteger(item.packageCount) || item.packageCount < 1 || item.packageCount > 1_000_000) itemErrors.push('箱数必须是 1 至 1,000,000 的整数')
|
||||
if (!isPositive(item.lengthCm) || !isPositive(item.widthCm) || !isPositive(item.heightCm)) itemErrors.push('长、宽、高必须大于 0')
|
||||
if (!isPositive(item.unitWeightKg)) itemErrors.push('单箱重量必须大于 0')
|
||||
if (![item.outerLengthCm, item.outerWidthCm, item.outerHeightCm].every(isNonNegative)) itemErrors.push('外包装尺寸不能小于 0')
|
||||
if (item.intelligentImageFileId && !/^\d+$/.test(item.intelligentImageFileId)) itemErrors.push('测量图片文件 ID 必须是正整数')
|
||||
if ((item.overLimitWarning || '').length > 500 || (item.remark || '').length > 500) itemErrors.push('提示或备注不能超过 500 个字符')
|
||||
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
|
||||
})
|
||||
if (general.length) errors.general = general
|
||||
validationErrors.value = errors
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
function buildPayload(): SaveInboundMeasurementDraftRequest {
|
||||
return {
|
||||
measurementId: form.measurementId,
|
||||
version: form.version,
|
||||
originalDocumentNo: optionalText(form.originalDocumentNo),
|
||||
originalGrossWeightKg: form.originalGrossWeightKg,
|
||||
originalVolumeCbm: form.originalVolumeCbm,
|
||||
actualGoodsName: optionalText(form.actualGoodsName),
|
||||
items: form.items.map((item) => ({
|
||||
itemNo: item.itemNo.trim(),
|
||||
boxSpecification: item.boxSpecification.trim(),
|
||||
packageCount: item.packageCount,
|
||||
lengthCm: item.lengthCm,
|
||||
widthCm: item.widthCm,
|
||||
heightCm: item.heightCm,
|
||||
unitWeightKg: item.unitWeightKg,
|
||||
intelligentImageFileId: item.intelligentImageFileId || null,
|
||||
overLimitWarning: optionalText(item.overLimitWarning || ''),
|
||||
outerLengthCm: item.outerLengthCm,
|
||||
outerWidthCm: item.outerWidthCm,
|
||||
outerHeightCm: item.outerHeightCm,
|
||||
remark: optionalText(item.remark || ''),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
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('并发')
|
||||
return details
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!mayMaintain.value || !validateDraft()) return
|
||||
saving.value = true
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
try {
|
||||
const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, buildPayload())
|
||||
form.measurementId = String(saved.measurementId)
|
||||
form.version = saved.version
|
||||
ElMessage.success('测量草稿已保存,计算结果已由服务端更新')
|
||||
await loadMeasurement()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '测量草稿保存失败,请检查后重试')
|
||||
if (!conflictDetected.value) ElMessage.error(details.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForReview() {
|
||||
if (!maySubmit.value || !form.measurementId) return
|
||||
if (isDirty.value) {
|
||||
ElMessage.warning('请先保存当前修改,再提交审核')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'提交后将校验测量图片和全部称重结果,并由后端准备托盘与库位占用。提交成功后本批次不能继续编辑。',
|
||||
'提交测量审核',
|
||||
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
try {
|
||||
await submitInboundMeasurement(customerId.value, props.inboundId, {
|
||||
measurementId: form.measurementId,
|
||||
version: form.version,
|
||||
})
|
||||
ElMessage.success('测量数据已提交审核')
|
||||
await loadMeasurement()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
|
||||
if (!conflictDetected.value) ElMessage.error(details.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
|
||||
if (value == null || !Number.isFinite(value)) return '—'
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
|
||||
}
|
||||
|
||||
function processStatusLabel(status: string | undefined) {
|
||||
return ({
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '审核驳回',
|
||||
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用确认中', INBOUNDING: '入库中', INBOUNDED: '已入库', VOIDED: '已作废',
|
||||
} as Record<string, string>)[status || ''] || status || '—'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerId.value, props.inboundId, canView.value] as const,
|
||||
() => {
|
||||
requestSequence += 1
|
||||
detail.value = null
|
||||
loadError.value = null
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
validationErrors.value = {}
|
||||
clearForm()
|
||||
if (hasContext.value && canView.value) void loadMeasurement()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface-panel measurement-panel" aria-label="入库测量草稿">
|
||||
<header class="panel-heading measurement-panel__heading">
|
||||
<div>
|
||||
<span class="measurement-panel__icon"><Ruler :size="18" aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>测量与箱规</h2>
|
||||
<p>维护箱规原始值;体积、材积重和汇总结果均由服务端计算。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="measurement-panel__actions">
|
||||
<el-button :loading="loading" :disabled="!hasContext || !canView || saving || submitting" @click="reloadLatest">
|
||||
<RefreshCw :size="15" />刷新
|
||||
</el-button>
|
||||
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft">
|
||||
<Save :size="15" />保存草稿
|
||||
</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview">
|
||||
<Send :size="15" />提交审核
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-empty v-if="!hasContext" description="请选择所属企业和入库单后维护测量数据" />
|
||||
|
||||
<LoadErrorState
|
||||
v-else-if="!canView"
|
||||
title="无测量查看权限"
|
||||
message="当前账号缺少 platform:firstleg:inbound:measure:view 权限。"
|
||||
:show-retry="false"
|
||||
/>
|
||||
|
||||
<div v-else-if="loading && !detail" class="measurement-skeleton" aria-label="正在加载测量数据">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<LoadErrorState
|
||||
v-else-if="loadError && !detail"
|
||||
title="测量数据加载失败"
|
||||
:message="loadError.message"
|
||||
:loading="loading"
|
||||
@retry="loadMeasurement"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="measurement-context">
|
||||
<div class="measurement-context__identity">
|
||||
<div>
|
||||
<strong>{{ detail.inboundCode }}</strong>
|
||||
<span>{{ detail.companyName }} · {{ detail.channelName || detail.channelCode || '未设置渠道' }}</span>
|
||||
</div>
|
||||
<div class="measurement-tags">
|
||||
<el-tag :type="sourceTagType" effect="light">{{ sourceLabel }}</el-tag>
|
||||
<StatusBadge :status="processStatusLabel(detail.processStatus)" />
|
||||
<span>版本 {{ form.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="measurement-metrics">
|
||||
<div><dt>实测总箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, 0) }}</dd></div>
|
||||
<div><dt>实测总毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>T.K.GW</small></dd></div>
|
||||
<div><dt>实测总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>T.CBM</small></dd></div>
|
||||
<div><dt>实测总材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>KG</small></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="!editableProcess" type="info" :closable="false" show-icon>
|
||||
<template #title>当前状态为“{{ processStatusLabel(detail.processStatus) }}”,测量数据仅可查看</template>
|
||||
测量草稿只允许在待测量、测量中或审核驳回状态维护。
|
||||
</el-alert>
|
||||
|
||||
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
|
||||
|
||||
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>数据版本已变化,当前修改尚未丢失</template>
|
||||
<p>{{ operationError?.message }}</p>
|
||||
<ApiErrorMeta v-if="operationError" :details="operationError" />
|
||||
<el-button size="small" @click="reloadLatest">加载最新版本</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-else-if="operationError" type="error" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>操作未完成</template>
|
||||
<p>{{ operationError.message }}</p>
|
||||
<ApiErrorMeta :details="operationError" />
|
||||
</el-alert>
|
||||
|
||||
<el-alert v-if="Object.keys(validationErrors).length" type="error" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>请修正测量草稿中的问题</template>
|
||||
<ul>
|
||||
<li v-for="message in Object.values(validationErrors).flat()" :key="message">{{ message }}</li>
|
||||
</ul>
|
||||
</el-alert>
|
||||
|
||||
<form class="measurement-form" @submit.prevent="saveDraft">
|
||||
<fieldset :disabled="!mayMaintain || saving">
|
||||
<legend class="sr-only">测量草稿</legend>
|
||||
<section class="measurement-section">
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>原单数据</h3><p>原单值仅用于审核对比,不参与前端计算。</p></div>
|
||||
</div>
|
||||
<div class="measurement-form-grid">
|
||||
<label><span>原单据号码(CKW)</span><el-input v-model="form.originalDocumentNo" maxlength="64" placeholder="请输入原单据号码" /></label>
|
||||
<label><span>原单据毛重(T.K.GW)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" :controls="false" placeholder="0.000" /></label>
|
||||
<label><span>原单据总体积(T.CBM)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" :controls="false" placeholder="0.0000" /></label>
|
||||
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" maxlength="255" placeholder="请输入实际货物名称" /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="measurement-section measurement-items-section">
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>入库单测量数据明细</h3><p>最多 500 条;保存后由服务端重新计算箱规周长、单箱体积、总毛重、总体积和总材积重。</p></div>
|
||||
<el-button v-if="canEdit" :disabled="!mayMaintain || form.items.length >= 500" @click="addItem"><Plus :size="15" />新增箱规</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!form.items.length" description="暂无箱规明细">
|
||||
<el-button v-if="mayMaintain" type="primary" @click="addItem"><Plus :size="15" />添加第一条箱规</el-button>
|
||||
</el-empty>
|
||||
|
||||
<article v-for="(item, index) in form.items" :key="item.clientKey" class="measurement-item" :class="{ 'has-error': validationErrors[item.clientKey]?.length }">
|
||||
<header>
|
||||
<div><strong>箱规 {{ index + 1 }}</strong><span>{{ item.itemNo || '待填写序号' }} · {{ item.boxSpecification || '待填写箱规' }}</span></div>
|
||||
<el-button v-if="canEdit" link type="danger" :disabled="!mayMaintain || form.items.length === 1" @click="removeItem(index)"><Trash2 :size="15" />删除</el-button>
|
||||
</header>
|
||||
<div class="measurement-item__grid">
|
||||
<label><span>明细编号 *</span><el-input v-model="item.itemNo" maxlength="32" placeholder="例如 001" /></label>
|
||||
<label><span>箱规 *</span><el-input v-model="item.boxSpecification" maxlength="64" placeholder="例如 50×40×30cm" /></label>
|
||||
<label><span>箱规长(cm)*</span><el-input-number v-model="item.lengthCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱规宽(cm)*</span><el-input-number v-model="item.widthCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱规高(cm)*</span><el-input-number v-model="item.heightCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱数 *</span><el-input-number v-model="item.packageCount" :min="1" :max="1000000" :step="1" step-strictly controls-position="right" /></label>
|
||||
<label><span>每箱毛重(kg)*</span><el-input-number v-model="item.unitWeightKg" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>智能图片文件 ID</span><el-input v-model="item.intelligentImageFileId" inputmode="numeric" placeholder="文件服务返回的 ID" /></label>
|
||||
</div>
|
||||
<details class="measurement-item__advanced">
|
||||
<summary>外包装尺寸与补充说明</summary>
|
||||
<div class="measurement-item__grid">
|
||||
<label><span>外箱规长(cm)</span><el-input-number v-model="item.outerLengthCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label><span>外箱规宽(cm)</span><el-input-number v-model="item.outerWidthCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label><span>外箱规高(cm)</span><el-input-number v-model="item.outerHeightCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label class="span-two"><span>超限提醒</span><el-input v-model="item.overLimitWarning" maxlength="500" placeholder="设备或人工发现的尺寸异常" /></label>
|
||||
<label class="span-two"><span>备注</span><el-input v-model="item.remark" maxlength="500" placeholder="请输入本箱规的补充说明" /></label>
|
||||
</div>
|
||||
</details>
|
||||
<dl class="measurement-item__results" aria-label="服务端计算结果">
|
||||
<div><dt>箱规周长</dt><dd>{{ formatNumber(item.serverValues.girthCm) }} cm</dd></div>
|
||||
<div><dt>单箱体积</dt><dd>{{ formatNumber(item.serverValues.unitVolumeCbm, 4) }} CBM</dd></div>
|
||||
<div><dt>总毛重</dt><dd>{{ formatNumber(item.serverValues.totalWeightKg) }} KG</dd></div>
|
||||
<div><dt>总体积</dt><dd>{{ formatNumber(item.serverValues.totalVolumeCbm, 4) }} CBM</dd></div>
|
||||
<div><dt>总材积重</dt><dd>{{ formatNumber(item.serverValues.volumeWeightKg) }} KG</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
|
||||
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
|
||||
<span v-else>当前显示版本 {{ form.version }}</span>
|
||||
<div>
|
||||
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存草稿</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.measurement-panel { overflow: hidden; }
|
||||
.measurement-panel__heading > div:first-child { display: flex; min-width: 0; align-items: flex-start; gap: 11px; }
|
||||
.measurement-panel__icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.measurement-panel h2, .measurement-panel h3 { margin: 0; color: var(--cc-text); }
|
||||
.measurement-panel h2 { font-size: 16px; }
|
||||
.measurement-panel h3 { font-size: 14px; }
|
||||
.measurement-panel__heading p, .measurement-section__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
|
||||
.measurement-skeleton { padding: 24px; }
|
||||
.measurement-context { padding: 18px 20px; background: #fbfcfe; }
|
||||
.measurement-context__identity { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.measurement-context__identity > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.measurement-context__identity strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; }
|
||||
.measurement-context__identity span { color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.measurement-tags { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 7px; }
|
||||
.measurement-tags > span:last-child { color: var(--cc-muted); font-size: 11px; }
|
||||
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 16px 0 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.measurement-metrics > div { min-width: 0; padding: 13px 15px; border-right: 1px solid var(--cc-border); }
|
||||
.measurement-metrics > div:last-child { border-right: 0; }
|
||||
.measurement-metrics dt { color: var(--cc-muted); font-size: 11px; }
|
||||
.measurement-metrics dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; font-weight: 700; }
|
||||
.measurement-metrics small { color: var(--cc-muted); font-size: 10px; font-weight: 500; }
|
||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
|
||||
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
|
||||
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
|
||||
.measurement-alert .el-button { margin-top: 9px; }
|
||||
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.measurement-section { padding: 22px 20px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.measurement-section__heading > div { min-width: 0; }
|
||||
.measurement-form-grid, .measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px; }
|
||||
.measurement-form label, .measurement-item label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
|
||||
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { 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); }
|
||||
.measurement-item > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 15px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; }
|
||||
.measurement-item > header > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.measurement-item > header strong { color: var(--cc-text); font-size: 13px; }
|
||||
.measurement-item > header span { overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.measurement-item__grid { padding: 16px; }
|
||||
.measurement-item__advanced { margin: 0 16px 16px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-item__advanced summary { padding: 12px 0 0; color: var(--cc-primary); font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.measurement-item__advanced .measurement-item__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 14px 0 0; }
|
||||
.measurement-item__advanced .span-two { grid-column: span 3; }
|
||||
.measurement-item__results { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); background: #fbfcfe; }
|
||||
.measurement-item__results > div { min-width: 0; padding: 11px 14px; border-right: 1px solid var(--cc-border); }
|
||||
.measurement-item__results > div:last-child { border-right: 0; }
|
||||
.measurement-item__results dt { color: var(--cc-muted); font-size: 10px; }
|
||||
.measurement-item__results dd { margin: 4px 0 0; overflow-wrap: anywhere; color: #344054; font-size: 12px; font-weight: 700; }
|
||||
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 20px; border-top: 1px solid var(--cc-border); background: rgba(255, 255, 255, .97); box-shadow: 0 -8px 22px rgba(15, 23, 42, .05); }
|
||||
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
|
||||
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.measurement-form-grid, .measurement-item__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-metrics > div:nth-child(2) { border-right: 0; }
|
||||
.measurement-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-item__results { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.measurement-item__results > div:nth-child(3) { border-right: 0; }
|
||||
.measurement-item__results > div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.measurement-panel__heading, .measurement-context__identity, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
|
||||
.measurement-panel__actions, .measurement-panel__actions :deep(.el-button), .measurement-panel__footer > div, .measurement-panel__footer :deep(.el-button) { width: 100%; }
|
||||
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button), .measurement-section__heading :deep(.el-button) { min-height: 44px; }
|
||||
.measurement-tags { justify-content: flex-start; }
|
||||
.measurement-context, .measurement-section { padding-right: 14px; padding-left: 14px; }
|
||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
|
||||
.measurement-form-grid, .measurement-item__grid, .measurement-item__advanced .measurement-item__grid { grid-template-columns: 1fr; }
|
||||
.measurement-item__advanced .span-two { grid-column: auto; }
|
||||
.measurement-item__results { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-item__results > div { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-item__results > div:nth-child(even) { border-right: 0; }
|
||||
.measurement-item__results > div:last-child { border-bottom: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.measurement-metrics, .measurement-item__results { grid-template-columns: 1fr; }
|
||||
.measurement-metrics > div, .measurement-item__results > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-metrics > div:last-child, .measurement-item__results > div:last-child { border-bottom: 0; }
|
||||
.measurement-item > header { align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api'
|
||||
import type { SaveInboundMeasurementDraftRequest } from './types'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), put: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const draft: SaveInboundMeasurementDraftRequest = {
|
||||
measurementId: '9001',
|
||||
version: 2,
|
||||
originalDocumentNo: 'DOC-01',
|
||||
originalGrossWeightKg: 120,
|
||||
originalVolumeCbm: 1.2,
|
||||
actualGoodsName: '蓝牙音箱',
|
||||
items: [{
|
||||
itemNo: '001',
|
||||
boxSpecification: '50×40×30cm',
|
||||
packageCount: 10,
|
||||
lengthCm: 50,
|
||||
widthCm: 40,
|
||||
heightCm: 30,
|
||||
unitWeightKg: 12,
|
||||
intelligentImageFileId: '88001',
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: null,
|
||||
}],
|
||||
}
|
||||
|
||||
describe('inbound measurement API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
})
|
||||
|
||||
it('maps the selected customer to the backend tenant compatibility parameter', async () => {
|
||||
const detail = { inboundId: '7001', items: [], weightResults: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(getInboundMeasurement('10001', '7001')).resolves.toEqual(detail)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/inbounds/7001/measurement', {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('saves a versioned draft without inventing an idempotency header', async () => {
|
||||
const saved = { measurementId: '9001', version: 3, status: 'DRAFT' }
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: saved })
|
||||
|
||||
await expect(saveInboundMeasurementDraft('10001', '7001', draft)).resolves.toEqual(saved)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/7001/measurement/draft',
|
||||
draft,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('submits the server measurement id and optimistic-lock version', async () => {
|
||||
const submitted = { measurementId: '9001', version: 3, processStatus: 'MEASURE_PENDING' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: submitted })
|
||||
|
||||
await expect(submitInboundMeasurement('10001', '7001', { measurementId: '9001', version: 3 })).resolves.toEqual(submitted)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/7001/measurement/submit',
|
||||
{ measurementId: '9001', version: 3 },
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
InboundMeasurementDetail,
|
||||
InboundMeasurementDraftSaved,
|
||||
InboundMeasurementSubmitted,
|
||||
SaveInboundMeasurementDraftRequest,
|
||||
SubmitInboundMeasurementRequest,
|
||||
} from './types'
|
||||
|
||||
function measurementPath(inboundId: string) {
|
||||
return `/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement`
|
||||
}
|
||||
|
||||
export async function getInboundMeasurement(customerId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<InboundMeasurementDetail>, ApiEnvelope<InboundMeasurementDetail>>(
|
||||
measurementPath(inboundId),
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function saveInboundMeasurementDraft(
|
||||
customerId: string,
|
||||
inboundId: string,
|
||||
payload: SaveInboundMeasurementDraftRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<InboundMeasurementDraftSaved>, ApiEnvelope<InboundMeasurementDraftSaved>>(
|
||||
`${measurementPath(inboundId)}/draft`,
|
||||
payload,
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function submitInboundMeasurement(
|
||||
customerId: string,
|
||||
inboundId: string,
|
||||
payload: SubmitInboundMeasurementRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<InboundMeasurementSubmitted>, ApiEnvelope<InboundMeasurementSubmitted>>(
|
||||
`${measurementPath(inboundId)}/submit`,
|
||||
payload,
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as InboundMeasurementPanel } from './InboundMeasurementPanel.vue'
|
||||
export * from './api'
|
||||
export type * from './types'
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
export type MeasurementIdentifier = string
|
||||
|
||||
export type InboundMeasurementProcessStatus =
|
||||
| 'WAIT_MEASURE'
|
||||
| 'MEASURING'
|
||||
| 'MEASURE_PENDING'
|
||||
| 'MEASURE_REJECTED'
|
||||
| 'WAIT_PRICING'
|
||||
| 'FEE_CONFIRMING'
|
||||
| 'INBOUNDING'
|
||||
| 'INBOUNDED'
|
||||
| 'VOIDED'
|
||||
|
||||
export interface InboundMeasurementItem {
|
||||
id: MeasurementIdentifier
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
lengthCm: number
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
girthCm: number | null
|
||||
unitWeightKg: number
|
||||
unitVolumeCbm: number | null
|
||||
totalWeightKg: number | null
|
||||
totalVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface InboundMeasurementWeightResult {
|
||||
id: MeasurementIdentifier
|
||||
fileId: MeasurementIdentifier
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
recognitionStatus: string
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: MeasurementIdentifier | null
|
||||
adjustedAt: string | null
|
||||
confirmedBy: MeasurementIdentifier | null
|
||||
confirmedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface InboundMeasurementDetail {
|
||||
inboundId: MeasurementIdentifier
|
||||
inboundCode: string
|
||||
companyName: string
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
processStatus: InboundMeasurementProcessStatus
|
||||
measureReviewStatus: string | null
|
||||
recommendedPalletType: string | null
|
||||
reviewReason: string | null
|
||||
reviewerId: MeasurementIdentifier | null
|
||||
reviewerName: string | null
|
||||
measurementId: MeasurementIdentifier | null
|
||||
sourceType: string | null
|
||||
measurementStatus: string | null
|
||||
version: number | null
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
items: InboundMeasurementItem[]
|
||||
weightResults: InboundMeasurementWeightResult[]
|
||||
}
|
||||
|
||||
export interface SaveInboundMeasurementItemRequest {
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
lengthCm: number
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
unitWeightKg: number
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface SaveInboundMeasurementDraftRequest {
|
||||
measurementId: MeasurementIdentifier | null
|
||||
version: number
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualGoodsName: string | null
|
||||
items: SaveInboundMeasurementItemRequest[]
|
||||
}
|
||||
|
||||
export interface InboundMeasurementDraftSaved {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface SubmitInboundMeasurementRequest {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface InboundMeasurementSubmitted {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
processStatus: string
|
||||
measureReviewStatus: string
|
||||
recommendedPalletType: string | null
|
||||
feeId: MeasurementIdentifier | null
|
||||
feeNo: string | null
|
||||
feeStatus: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user