This commit is contained in:
2026-09-11 16:17:21 +08:00
parent 80a4b3ab5e
commit 737dd41ff1
24 changed files with 202 additions and 229 deletions
@@ -21,7 +21,8 @@ describe('InboundMeasurementDialog', () => {
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
expect(source).toContain('measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)')
expect(source).toContain('measurementPanelRef.value?.updateWeightEvidence(actualGrossWeightKg, actualNetWeightKg, hasConfirmedWeight)')
expect(source).toContain('measurementPanelRef?.submissionBlockReason')
})
it('offers the shared fullscreen control without forcing fullscreen on open', () => {
@@ -45,8 +45,12 @@ async function beforeClose(done: () => void) {
if (await mayClose()) done()
}
function handleWeightSummaryChanged(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)
function handleWeightSummaryChanged(
actualGrossWeightKg: number | null,
actualNetWeightKg: number | null,
hasConfirmedWeight: boolean,
) {
measurementPanelRef.value?.updateWeightEvidence(actualGrossWeightKg, actualNetWeightKg, hasConfirmedWeight)
}
function handleUpdated() {
@@ -95,6 +99,9 @@ function handleSubmitted() {
</div>
<template #footer>
<div class="measurement-dialog-footer">
<span v-if="measurementPanelRef?.submissionBlockReason" class="measurement-dialog-footer__hint">
{{ measurementPanelRef.submissionBlockReason }}
</span>
<el-button :disabled="Boolean(measurementPanelRef?.saving || measurementPanelRef?.submitting)" @click="requestClose">取消</el-button>
<el-button
v-if="measurementPanelRef?.canEdit"
@@ -119,7 +126,8 @@ function handleSubmitted() {
<style scoped>
.measurement-dialog-workspace { display: grid; min-width: 0; }
.measurement-dialog-weight { min-width: 0; padding: 0 0 16px; border-bottom: 1px solid var(--cc-border); }
.measurement-dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.measurement-dialog-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
.measurement-dialog-footer__hint { margin-right: auto; color: var(--el-color-warning-dark-2); font-size: 12px; }
.measurement-dialog-footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) {
.measurement-dialog-weight { padding-bottom: 14px; }
@@ -112,6 +112,10 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
outerLengthCm: null,
outerWidthCm: null,
outerHeightCm: null,
calculatedOuterLengthCm: 50,
calculatedOuterWidthCm: 40,
calculatedOuterHeightCm: 30,
calculationRuleVersion: 'DIMENSION_ACTUAL_V1',
remark: '设备自动回传',
}],
weightResults: [{
@@ -278,7 +282,7 @@ describe('InboundMeasurementPanel', () => {
expect(source).toContain('.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlockReason, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
})
it('renders customer declaration facts as plain read-only values', async () => {
@@ -296,6 +300,8 @@ describe('InboundMeasurementPanel', () => {
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(4)
expect(declaration.findAll('input')).toHaveLength(1)
expect(declaration.find('.measurement-goods-name input').exists()).toBe(true)
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('蓝牙音箱')
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30')
})
it('does not render differences when the corresponding declaration is unavailable', async () => {
@@ -352,7 +358,7 @@ describe('InboundMeasurementPanel', () => {
const countInput = wrapper.find('.measurement-count-editor input')
await countInput.setValue('9')
wrapper.vm.updateActualWeights(125.75, 115.75)
wrapper.vm.updateWeightEvidence(125.75, 115.75, true)
await flushPromises()
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
@@ -360,6 +366,22 @@ describe('InboundMeasurementPanel', () => {
expect((countInput.element as HTMLInputElement).value).toBe('9')
})
it('shows the complete server-calculated measurement facts in the expanded row', async () => {
const wrapper = await mountPanel()
await wrapper.get('.el-table__expand-icon').trigger('click')
await flushPromises()
const text = wrapper.text()
expect(text).toContain('箱规')
expect(text).toContain('50×40×30cm')
expect(text).toContain('计算方式')
expect(text).toContain('实测原尺寸')
expect(text).toContain('计算后外箱')
expect(text).toContain('50 × 40 × 30 cm')
expect(text).toContain('计算规则版本')
expect(text).toContain('DIMENSION_ACTUAL_V1')
})
it('reuses the established first-leg detail surface and status patterns', async () => {
const wrapper = await mountPanel()
@@ -486,15 +508,32 @@ describe('InboundMeasurementPanel', () => {
})
it('requires at least one confirmed weight image before submitting for review', async () => {
const messageSpy = vi.spyOn(ElMessage, 'warning')
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ weightResults: [] }))
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('至少上传并确认一张称重图片后才能提交审核')
expect(wrapper.text()).toContain('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
expect(submit?.attributes('disabled')).toBeDefined()
await wrapper.vm.submitForReview()
expect(messageSpy).toHaveBeenCalledWith('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
expect(submitInboundMeasurement).not.toHaveBeenCalled()
})
it('updates submission eligibility when the weight panel confirms or removes the last image', async () => {
const wrapper = await mountPanel()
expect(wrapper.vm.maySubmit).toBe(true)
wrapper.vm.updateWeightEvidence(null, null, false)
await flushPromises()
expect(wrapper.vm.maySubmit).toBe(false)
expect(wrapper.vm.submissionBlockReason).toContain('必须上传并确认')
wrapper.vm.updateWeightEvidence(125.75, 115.75, true)
await flushPromises()
expect(wrapper.vm.maySubmit).toBe(true)
})
it('emits updated without closing the parent task after manual carton entry', async () => {
const wrapper = await mountPanel()
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
@@ -38,6 +38,10 @@ type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coeffici
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
chargeRuleId: string | null
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
calculatedOuterLengthCm: number | null
calculatedOuterWidthCm: number | null
calculatedOuterHeightCm: number | null
calculationRuleVersion: string | null
}
interface MeasurementFormModel {
@@ -77,6 +81,7 @@ const imagePreviewUrl = ref('')
const imagePreviewError = ref<ApiErrorDetails | null>(null)
const validationErrors = ref<Record<string, string[]>>({})
const baseline = ref('')
const confirmedWeightAvailable = ref(false)
let requestSequence = 0
let itemSequence = 0
@@ -114,10 +119,18 @@ const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => su
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
const hasConfirmedWeightResult = computed(() => detail.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true)
const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value)
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
const submissionBlockReason = computed(() => {
if (!canSubmit.value || !submitEditableProcess.value || !form.measurementId) return ''
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
if (!expectedPalletCountValid.value) return '请先填写并保存有效的预计托数后再提交审核'
if (isDirty.value) return '请先保存当前修改,再提交审核'
return ''
})
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))
@@ -302,10 +315,15 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
},
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
coefficientMode: item.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL',
calculatedOuterLengthCm: item.calculatedOuterLengthCm ?? null,
calculatedOuterWidthCm: item.calculatedOuterWidthCm ?? null,
calculatedOuterHeightCm: item.calculatedOuterHeightCm ?? null,
calculationRuleVersion: item.calculationRuleVersion ?? null,
}
}
function clearForm() {
confirmedWeightAvailable.value = false
form.measurementId = null
form.version = 0
form.actualGoodsName = ''
@@ -317,6 +335,7 @@ function clearForm() {
function applyDetail(resource: InboundMeasurementDetail) {
detail.value = resource
confirmedWeightAvailable.value = resource.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED')
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
form.version = resource.version ?? 0
form.actualGoodsName = resource.actualGoodsName || ''
@@ -486,6 +505,10 @@ async function saveDraft() {
}
async function submitForReview() {
if (!hasConfirmedWeightResult.value) {
ElMessage.warning('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
return
}
if (!maySubmit.value || !form.measurementId) return
if (!validateDraft()) return
if (isDirty.value) {
@@ -567,9 +590,18 @@ function processStatusLabel(status: string | undefined) {
return inboundStatusLabel(status, inboundProcessStatusLabels)
}
function updateActualWeights(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
function updateWeightEvidence(
actualGrossWeightKg: number | null,
actualNetWeightKg: number | null,
hasConfirmedWeight: boolean,
) {
if (!detail.value) return
detail.value = { ...detail.value, actualGrossWeightKg, actualNetWeightKg }
confirmedWeightAvailable.value = hasConfirmedWeight
}
function calculationModeLabel(value: EditableMeasurementItem['coefficientMode']) {
return value === 'ADD' ? '箱规尺寸' : value === 'MULTIPLY' ? '箱规系数' : '实测原尺寸'
}
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
@@ -581,6 +613,7 @@ watch(
() => {
requestSequence += 1
detail.value = null
confirmedWeightAvailable.value = false
loadError.value = null
operationError.value = null
conflictDetected.value = false
@@ -593,7 +626,7 @@ watch(
onBeforeUnmount(releaseImagePreview)
defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlockReason, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
</script>
<template>
@@ -639,6 +672,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
<el-alert
v-if="submissionBlockReason && !hasConfirmedWeightResult"
type="warning"
:closable="false"
show-icon
class="measurement-alert"
:title="submissionBlockReason"
/>
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
<template #title>数据版本已变化,当前修改尚未丢失</template>
<p>{{ operationError?.message }}</p>
@@ -699,6 +741,22 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<div v-else-if="isDropship" class="measurement-readonly-field"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
</div>
<el-table
v-if="detail.latestDeclaration?.items.length"
:data="detail.latestDeclaration.items"
border
stripe
size="small"
class="measurement-declaration-table"
aria-label="客户申报箱规明细"
>
<el-table-column prop="itemNo" label="#" width="70" />
<el-table-column prop="cargoName" label="货物品名" min-width="150" show-overflow-tooltip />
<el-table-column prop="packageCount" label="申报箱数" width="100" />
<el-table-column label="申报尺寸 长×宽×高(cm)" min-width="190">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
</el-table>
</section>
</section>
@@ -722,11 +780,16 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<el-table-column type="expand" width="42">
<template #default="{ row }">
<dl class="measurement-row-details">
<div><dt>箱规</dt><dd>{{ row.boxSpecification || '—' }}</dd></div>
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
<div><dt>计算方式</dt><dd>{{ calculationModeLabel(row.coefficientMode) }}</dd></div>
<div><dt>计算后外箱</dt><dd>{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm</dd></div>
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
<div><dt>计算规则版本</dt><dd>{{ row.calculationRuleVersion || '—' }}</dd></div>
<div><dt>超限提醒</dt><dd>{{ row.overLimitWarning || '—' }}</dd></div>
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
</dl>
</template>
@@ -848,6 +911,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
.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-declaration-table { margin-top: 12px; }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
.measurement-skeleton { padding: 24px; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
@@ -447,7 +447,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5]])
expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5, true]])
expect(wrapper.emitted('changed')).toHaveLength(1)
expect(wrapper.text()).toContain('实测毛重')
expect(wrapper.text()).toContain('货物重量')
@@ -8,7 +8,7 @@ describe('WeightOcrPanel contract', () => {
it('uses customer language at the page boundary while retaining a compatibility input', () => {
expect(source).toContain('reviewMode?: boolean')
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null]')
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]')
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
expect(template).not.toContain('租户')
@@ -46,7 +46,7 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain('<div><dt>库位</dt><dd')
expect(source).not.toContain('workspace.palletLocationGuide.slots')
expect(source).toContain('对应库位仍为本票保留,不会释放给其他订单')
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)")
expect(source).toContain("workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true")
})
it('matches backend image limits and keeps technical OCR data out of the list', () => {
@@ -41,7 +41,7 @@ const props = withDefaults(defineProps<{
})
const emit = defineEmits<{
changed: []
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null]
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]
}>()
const access = useAccessPreviewStore()
@@ -213,7 +213,12 @@ function buildAdjustmentReason(row: WeightResultResource) {
}
function emitWorkspaceChanged() {
emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)
emit(
'summaryChanged',
workspace.value?.actualGrossWeightKg ?? null,
workspace.value?.actualNetWeightKg ?? null,
workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true,
)
emit('changed')
}