This commit is contained in:
2026-08-17 18:00:42 +08:00
parent c7334b2cf8
commit ec3dbb3fe4
23 changed files with 407 additions and 999 deletions
@@ -16,6 +16,18 @@ function activeCustomer(id: string, tenantName: string, legalName: string | null
}
describe('InboundTaskList measurement scope', () => {
it('supports selecting only pending measurement reviews for batch approval', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
expect(source).toContain("key: 'selection', type: 'selection'")
expect(source).toContain("row.measureReviewStatus === 'PENDING'")
expect(source).toContain('@selection-change="handleReviewSelectionChange"')
expect(source).toContain('batchApproveMeasurements')
expect(source).toContain('批量审核通过')
expect(source).toContain('getMeasurementReviewDetail')
expect(source).toContain('approveMeasurement')
})
it('uses the inbound tenant isolation key for measurement and review requests', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Ruler, SearchCheck } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckBig, Eye, Ruler, SearchCheck } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
@@ -16,6 +17,7 @@ import { listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary } from '../core/types'
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
import { approveMeasurement, getMeasurementReviewDetail } from '../review/api'
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import { buildInboundTaskQuery, loadAllActiveCustomers, type InboundTaskMode } from '../task-list'
@@ -42,6 +44,8 @@ const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const selectedInbound = ref<InboundOrderSummary | null>(null)
const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false)
const batchReviewLoading = ref(false)
const selectedReviewRows = ref<InboundOrderSummary[]>([])
let requestSerial = 0
let optionsRequestSerial = 0
let disposed = false
@@ -73,8 +77,12 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
])
const columns: DataTableColumn[] = [
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
...(isReview.value && access.can(actionPermission.value) ? [{
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: (row: InboundOrderSummary) => row.measureReviewStatus === 'PENDING' && !batchReviewLoading.value,
} satisfies DataTableColumn] : []),
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
{ key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus' },
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
@@ -88,7 +96,7 @@ const columns: DataTableColumn[] = [
{ key: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 128, slotName: 'actualGrossWeightKg' },
{ key: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 128, slotName: 'actualVolumeCbm' },
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
]
])
function text(value: unknown) {
return value === null || value === undefined || value === '' ? '—' : String(value)
@@ -129,6 +137,7 @@ async function loadOptions() {
}
async function loadTasks() {
selectedReviewRows.value = []
const serial = ++requestSerial
loading.value = true
loadError.value = null
@@ -182,6 +191,48 @@ function handleTaskChanged() {
void loadTasks()
}
function handleReviewSelectionChange(rows: InboundOrderSummary[]) {
selectedReviewRows.value = rows.filter((row) => row.measureReviewStatus === 'PENDING')
}
async function batchApproveMeasurements() {
const selected = [...selectedReviewRows.value]
if (!selected.length || batchReviewLoading.value) return
batchReviewLoading.value = true
try {
await ElMessageBox.confirm(
`确认批量审核通过已勾选的 ${selected.length} 条测量数据吗?通过后将进入待分柜。`,
'批量审核测量数据',
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
)
let successCount = 0
let failureCount = 0
const failures: string[] = []
for (const row of selected) {
try {
const detail = await getMeasurementReviewDetail(String(row.tenantId), String(row.id))
await approveMeasurement(String(row.tenantId), String(row.id), {
measurementId: detail.measurementId,
expectedVersion: detail.version,
})
successCount += 1
} catch (error) {
failureCount += 1
failures.push(getApiErrorDetails(error, `${row.inboundCode || row.id} 审核失败`).message)
}
}
await loadTasks()
const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';')
if (!failureCount) ElMessage.success(`已批量审核通过 ${successCount} 条测量数据`)
else if (successCount) ElMessage.warning(`批量审核完成:成功 ${successCount} 条,失败 ${failureCount} 条${reason ? `。${reason}` : ''}`)
else ElMessage.error(`批量审核失败:${failureCount} 条测量数据未通过${reason ? `。${reason}` : ''}`)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '批量审核失败').message)
} finally {
batchReviewLoading.value = false
}
}
function handleMeasurementUpdated() {
void loadTasks()
}
@@ -246,7 +297,11 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
row-key="id"
:empty-text="emptyText"
:aria-label="`${taskLabel}待办列表`"
@selection-change="handleReviewSelectionChange"
>
<template v-if="isReview && access.can(actionPermission)" #toolbar-actions>
<el-button type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button>
</template>
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }">
<div class="row-actions">
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundWorkflowWorkspace.vue'), 'utf8')
const measurementDialogSource = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/measurement/InboundMeasurementDialog.vue'), 'utf8')
describe('InboundWorkflowWorkspace', () => {
it('assembles every phase-two inbound domain behind one server-backed workspace', () => {
@@ -88,9 +89,10 @@ describe('InboundWorkflowWorkspace', () => {
})
it('uses semantic outlined or solid buttons for workflow launches and decisions', () => {
expect(source).toContain('<el-button type="primary" @click="measurementDialogOpen = true">')
expect(source).toContain('<el-button v-if="canViewMeasurement" type="primary" @click="measurementDialogOpen = true">')
expect(source).toContain('<el-button type="primary" @click="reviewDialogOpen = true">')
expect(source).toContain('type="primary" plain')
expect(measurementDialogSource).toContain('type="primary"')
expect(measurementDialogSource).toContain('plain')
expect(source).toContain('type="danger" plain')
expect(source).toContain('type="warning" plain')
expect(source).toContain('<Pencil :size="16" />编辑测量数据')
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/measurement/components/MeasurementEvidenceThumbnail.vue'), 'utf8')
const weightWrapperSource = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/weight/components/WeightImageThumbnail.vue'), 'utf8')
describe('MeasurementEvidenceThumbnail', () => {
it('uses the authenticated measurement thumbnail API with stale-request and object-url cleanup', () => {
expect(source).toContain("import { downloadWeightThumbnail } from '../../weight/api/weightOcr'")
expect(source).toContain('downloadWeightThumbnail(props.customerId, props.inboundId, props.fileId)')
expect(source).toContain('currentRequest !== requestId')
expect(source).toContain('URL.revokeObjectURL(imageUrl.value)')
})
it('renders explicit loading, failure and missing-file states', () => {
expect(source).toContain("fileId?: string | null")
expect(source).toContain("!fileId ? emptyText : failed ? '图片加载失败'")
expect(source).toContain(':disabled="!fileId"')
})
it('is also the evidence thumbnail used by the editable OCR workspace', () => {
expect(weightWrapperSource).toContain("import MeasurementEvidenceThumbnail from '../../measurement/components/MeasurementEvidenceThumbnail.vue'")
expect(weightWrapperSource).toContain('<MeasurementEvidenceThumbnail')
expect(weightWrapperSource).toContain('empty-text="未绑定称重图片"')
})
})
@@ -64,7 +64,7 @@ onBeforeUnmount(() => {
<img v-if="imageUrl" :src="imageUrl" :alt="alt" loading="lazy" />
<span v-else class="measurement-evidence-thumb__state" :class="{ 'is-loading': loading }">
<ImageOff v-if="failed || !fileId" :size="22" aria-hidden="true" />
<span>{{ !fileId ? emptyText : failed ? '图片加载失败,点击重试' : '图片加载中' }}</span>
<span>{{ !fileId ? emptyText : failed ? '图片加载失败' : '图片加载中' }}</span>
</span>
</button>
</template>
@@ -67,9 +67,11 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).toContain('称重图片')
expect(wrapper.text()).toContain('货物重量')
expect(wrapper.text()).toContain('110 kg')
expect(wrapper.text()).toContain('容量 1.2 m³')
expect(wrapper.text()).toContain('托盘类型:木托盘')
expect(wrapper.text()).toContain('服务端确认毛重:120 kg')
expect(wrapper.text()).toContain('托盘皮重:10 kg')
expect(wrapper.text()).not.toContain('TP-001')
expect(wrapper.text()).not.toContain('申报毛重')
expect(wrapper.text()).not.toContain('托盘皮重')
expect(wrapper.text()).not.toContain('OCR 原文')
expect(wrapper.text()).not.toContain('设备屏幕原始识别内容')
expect(wrapper.text()).not.toContain('置信度')
@@ -126,13 +128,27 @@ describe('MeasurementReviewPanel', () => {
expect(downloadWeightImage).toHaveBeenCalledWith('10001', '90001', 'file-1')
const preview = document.body.querySelector('.weight-preview')
expect(preview?.textContent).toContain('TP-001 · 木托盘')
expect(preview?.textContent).toContain('托盘类型')
expect(preview?.textContent).toContain('木托盘')
expect(preview?.textContent).not.toContain('TP-001')
expect(preview?.textContent).toContain('120 kg')
expect(preview?.textContent).toContain('10 kg')
expect(preview?.textContent).toContain('110 kg')
expect(preview?.querySelector('img')?.getAttribute('src')).toBe('blob:weight-original')
})
it('shows the empty pallet type explicitly without falling back to the pallet code', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValueOnce({
...detail,
weightResults: [{ ...detail.weightResults[0], palletType: '' }],
})
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('托盘类型:未记录类型')
expect(wrapper.text()).not.toContain('TP-001')
})
it('shows the rejected release action only when the permission and rejected state exist', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '重量图片不清晰' })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
@@ -288,8 +288,9 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
<strong>{{ formatNumber(row.netWeightKg, ' kg') }}</strong>
</div>
<div class="weight-evidence-card__record">
<span>{{ row.palletCode }} · {{ row.palletType }}</span>
<span>容量 {{ formatNumber(row.palletCapacityCbm, ' m³') }}</span>
<span>托盘类型:{{ row.palletType || '未记录类型' }}</span>
<span>服务端确认毛重:{{ formatNumber(row.grossWeightKg, ' kg') }}</span>
<span>托盘皮重:{{ formatNumber(row.palletTareWeightKg, ' kg') }}</span>
</div>
</article>
</div>
@@ -330,8 +331,8 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
</el-alert>
</div>
<dl class="weight-preview__facts">
<div><dt>托盘</dt><dd>{{ previewResult.palletCode }} · {{ previewResult.palletType }}</dd></div>
<div><dt>含托盘重量</dt><dd>{{ formatNumber(previewResult.grossWeightKg, ' kg') }}</dd></div>
<div><dt>托盘类型</dt><dd>{{ previewResult.palletType || '未记录类型' }}</dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatNumber(previewResult.grossWeightKg, ' kg') }}</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatNumber(previewResult.palletTareWeightKg, ' kg') }}</dd></div>
<div><dt>货物重量</dt><dd>{{ formatNumber(previewResult.netWeightKg, ' kg') }}</dd></div>
</dl>
@@ -13,6 +13,7 @@ defineEmits<{ open: [] }>()
<template>
<MeasurementEvidenceThumbnail
class="weight-thumb"
:customer-id="props.customerId"
:inbound-id="props.inboundId"
:file-id="props.fileId"
@@ -221,7 +221,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(dialogText).toContain('OCR 识别毛重')
expect(dialogText).toContain('285.500 KG')
expect(dialogText).toContain('确认含托盘毛重')
for (const hiddenLabel of ['托盘编码', '托盘类型', '托盘容量', '识别状态', '置信度', '服务端毛重', '服务端净重', 'OCR 原文']) {
expect(dialogText).toContain('托盘类型')
expect(dialogText).toContain('木托盘')
expect(dialogText).not.toContain('TP-001')
for (const hiddenLabel of ['托盘编码', '托盘容量', '识别状态', '置信度', '服务端毛重', '服务端净重', 'OCR 原文']) {
expect(dialogText).not.toContain(hiddenLabel)
}
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
@@ -25,6 +25,7 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain('class="weight-card__value"')
expect(source).toContain('v-model="weightDrafts[previewResult.id].value"')
expect(source).toContain('当前托盘皮重')
expect(source).toContain("previewResult.palletType || '未记录类型'")
expect(source).toContain('OCR 识别毛重')
expect(source).toContain('确认含托盘毛重')
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
@@ -44,10 +45,12 @@ describe('WeightOcrPanel contract', () => {
expect(source).not.toContain('<span>置信度</span>')
expect(source).not.toContain('<span>OCR 原文</span>')
expect(source).not.toContain('<span>托盘编码</span>')
expect(source).not.toContain('<span>托盘类型</span>')
expect(source).toContain('<span>托盘类型</span>')
expect(source).not.toContain('<span>托盘容量</span>')
expect(source).not.toContain('<span>服务端毛重</span>')
expect(source).not.toContain('<span>服务端净重</span>')
expect(source).not.toContain('`${option.palletCode} · ${option.palletType}`')
expect(source).toContain("option.palletType || '未记录类型'")
})
it('gates every action with the implemented permissions and editable statuses', () => {
@@ -400,7 +400,7 @@ defineExpose({ reload: loadWorkspace })
<el-option
v-for="option in palletOptions"
:key="option.palletId"
:label="`${option.palletCode} · ${option.palletType}`"
:label="option.palletType || '未记录类型'"
:value="option.palletId"
/>
</el-select>
@@ -499,10 +499,10 @@ defineExpose({ reload: loadWorkspace })
@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>{{ row.palletType || '未记录类型' }}</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>
<div><dt>货物重量</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
</dl>
</div>
</article>
@@ -535,6 +535,7 @@ defineExpose({ reload: loadWorkspace })
<template v-else-if="previewResult">
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
<div class="weight-key-facts" aria-label="称重关键数据">
<div><span>托盘类型</span><strong>{{ previewResult.palletType || '未记录类型' }}</strong></div>
<div><span>当前托盘皮重</span><strong>{{ formatWeight(previewResult.palletTareWeightKg) }}</strong></div>
<div><span>OCR 识别毛重</span><strong>{{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || 'KG') }}</strong></div>
</div>