This commit is contained in:
2026-08-04 21:01:39 +08:00
parent 88bd939170
commit 5cb1ca307d
77 changed files with 9914 additions and 131 deletions
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, CircleAlert, History, RotateCcw, ShieldAlert, X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import {
approveMeasurement,
getMeasurementReviewDetail,
listMeasurementReviewHistory,
rejectMeasurement,
releaseRejectedMeasurement,
} from './api'
import type { MeasurementReviewDetail, MeasurementReviewItem, MeasurementReviewRecord } from './types'
const props = defineProps<{
customerId?: string
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
}>()
const access = useAccessPreviewStore()
const detail = ref<MeasurementReviewDetail | null>(null)
const history = ref<MeasurementReviewRecord[]>([])
const loading = ref(false)
const historyLoading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const historyError = ref<ApiErrorDetails | null>(null)
const reasonDialog = ref<'reject' | 'release' | null>(null)
const reason = ref('')
const requestSerial = ref(0)
const reasonDialogOpen = computed({
get: () => reasonDialog.value !== null,
set: (open: boolean) => { if (!open) reasonDialog.value = null },
})
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
const canRelease = computed(() => access.can('firstleg:inbound:exception-release'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.finalWeightKg !== null).length ?? 0)
const reviewStatusLabel = computed(() => {
if (isPending.value) return '待审核'
if (isRejected.value) return '已驳回'
if (detail.value?.measureReviewStatus === 'APPROVED') return '已通过'
return detail.value?.measureReviewStatus || '未提交'
})
const measurementColumns: DataTableColumn[] = [
{ key: 'boxSpecification', label: '箱规', minWidth: 130, slotName: 'boxSpecification' },
{ key: 'packageCount', label: '箱数', width: 80, slotName: 'packageCount' },
{ key: 'dimensions', label: '长 × 宽 × 高(cm)', minWidth: 170, slotName: 'dimensions' },
{ key: 'outerDimensions', label: '外箱长 × 宽 × 高(cm)', minWidth: 190, slotName: 'outerDimensions' },
{ key: 'girthCm', label: '周长(cm)', minWidth: 105, slotName: 'girthCm' },
{ key: 'unitWeightKg', label: '每箱毛重(kg)', minWidth: 125, slotName: 'unitWeightKg' },
{ key: 'unitVolumeCbm', label: '每箱体积(m³)', minWidth: 125, slotName: 'unitVolumeCbm' },
{ key: 'totalWeightKg', label: '总毛重(kg)', minWidth: 110, slotName: 'totalWeightKg' },
{ key: 'totalVolumeCbm', label: '总体积(m³)', minWidth: 110, slotName: 'totalVolumeCbm' },
{ key: 'volumeWeightKg', label: '材积重(kg)', minWidth: 110, slotName: 'volumeWeightKg' },
{ key: 'note', label: '超限提示 / 备注', minWidth: 200, slotName: 'note' },
]
function formatNumber(value: number | null | undefined, unit = '') {
if (value === null || value === undefined || Number.isNaN(value)) return '-'
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '-'
}
function resultLabel(value: string | null | undefined) {
if (value === 'APPROVED') return '审核通过'
if (value === 'REJECTED') return '审核驳回'
return value || '未知结果'
}
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return details.code === 'A0409' || details.code === '409' || /并发|版本|已被更新|状态已变化/.test(details.message)
}
async function loadReview() {
const serial = ++requestSerial.value
loadError.value = null
loading.value = true
try {
const result = await getMeasurementReviewDetail(customerId.value, props.inboundId)
if (serial === requestSerial.value) detail.value = result
} catch (error) {
if (serial === requestSerial.value) {
detail.value = null
loadError.value = getApiErrorDetails(error, '测量审核数据加载失败')
}
} finally {
if (serial === requestSerial.value) loading.value = false
}
}
async function loadHistory() {
historyError.value = null
historyLoading.value = true
try {
const result = await listMeasurementReviewHistory(customerId.value, props.inboundId)
history.value = result.records
} catch (error) {
history.value = []
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
} finally {
historyLoading.value = false
}
}
async function reload() {
await Promise.all([loadReview(), loadHistory()])
}
async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return
try {
await ElMessageBox.confirm(
'审核通过后,系统会自动试算并发布费用,同时完成托盘库位占用和入库。客户无需再次确认费用。是否继续?',
'确认测量审核通过',
{ confirmButtonText: '通过并完成入库', cancelButtonText: '返回', type: 'warning' },
)
} catch {
return
}
actionLoading.value = 'approve'
try {
await approveMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
})
ElMessage.success('审核已通过,费用发布和入库正在完成')
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
function openReasonDialog(kind: 'reject' | 'release') {
reason.value = ''
reasonDialog.value = kind
}
async function submitReason() {
if (!detail.value || !reasonDialog.value) return
const value = reason.value.trim()
if (!value) {
ElMessage.warning(reasonDialog.value === 'reject' ? '请填写驳回原因' : '请填写异常放行原因')
return
}
const kind = reasonDialog.value
actionLoading.value = kind
try {
if (kind === 'reject') {
await rejectMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
reason: value,
})
ElMessage.success('测量已驳回,已退回测量修正')
} else {
await releaseRejectedMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
reason: value,
})
ElMessage.success('异常放行已提交,后续计费入库将继续处理')
}
reasonDialog.value = null
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重试' : getApiErrorDetails(error, kind === 'reject' ? '测量驳回失败' : '异常放行失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
watch(() => [customerId.value, props.inboundId], () => {
if (customerId.value && props.inboundId) void reload()
}, { immediate: true })
onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
</script>
<template>
<section class="review-panel" aria-labelledby="measurement-review-title">
<header class="review-header">
<div>
<h2 id="measurement-review-title">测量审核</h2>
<p>核对原单、设备实测和称重 OCR 后,再决定是否进入自动计费入库。</p>
</div>
<div class="review-header-status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</header>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="review-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="reload">重新加载</el-button>
</el-alert>
<el-skeleton v-if="loading && !detail" :rows="8" animated />
<template v-else-if="detail">
<div class="comparison-grid">
<article class="comparison-card">
<span class="comparison-label">原单申报</span>
<strong>{{ detail.originalDocumentNo || detail.inboundCode || '-' }}</strong>
<dl>
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
</dl>
</article>
<article class="comparison-card">
<span class="comparison-label">设备实测</span>
<strong>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</strong>
<dl>
<div><dt>实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
</dl>
</article>
<article class="comparison-card">
<span class="comparison-label">称重 OCR</span>
<strong>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</strong>
<dl>
<div><dt>已确认图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
<div><dt>测量来源</dt><dd>{{ detail.sourceType || '-' }}</dd></div>
</dl>
</article>
</div>
<div v-if="detail.reviewReason" class="review-reason">
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
</div>
<div class="review-section">
<div class="section-heading"><div><h3>三组数据对比</h3><p>{{ detail.companyName || '-' }} · {{ detail.channelName || detail.channelCode || '-' }}</p></div><span class="section-count">{{ detail.items.length }} 条箱规</span></div>
<DataTable :data="detail.items" :columns="measurementColumns" empty-text="暂无设备测量明细" border stripe>
<template #boxSpecification="{ row }">{{ (row as MeasurementReviewItem).boxSpecification || (row as MeasurementReviewItem).itemNo || '-' }}</template>
<template #packageCount="{ row }">{{ (row as MeasurementReviewItem).packageCount ?? '-' }}</template>
<template #dimensions="{ row }">{{ [(row as MeasurementReviewItem).lengthCm, (row as MeasurementReviewItem).widthCm, (row as MeasurementReviewItem).heightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
<template #outerDimensions="{ row }">{{ [(row as MeasurementReviewItem).outerLengthCm, (row as MeasurementReviewItem).outerWidthCm, (row as MeasurementReviewItem).outerHeightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
<template #girthCm="{ row }">{{ formatNumber((row as MeasurementReviewItem).girthCm) }}</template>
<template #unitWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitWeightKg) }}</template>
<template #unitVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitVolumeCbm) }}</template>
<template #totalWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalWeightKg) }}</template>
<template #totalVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalVolumeCbm) }}</template>
<template #volumeWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).volumeWeightKg) }}</template>
<template #note="{ row }"><div class="measurement-note"><strong v-if="(row as MeasurementReviewItem).overLimitWarning">{{ (row as MeasurementReviewItem).overLimitWarning }}</strong><span>{{ (row as MeasurementReviewItem).remark || ((row as MeasurementReviewItem).overLimitWarning ? '' : '-') }}</span></div></template>
<template #empty><el-empty description="暂无设备测量明细" :image-size="48" /></template>
</DataTable>
</div>
<div class="review-section">
<div class="section-heading"><div><h3>称重图片识别结果</h3><p>原始 OCR 值不可覆盖,只有已确认且有效的结果会计入实际重量。</p></div><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
<div class="weight-list">
<div v-for="item in detail.weightResults" :key="item.id" class="weight-row">
<div><strong>{{ item.fileId || '未关联文件' }}</strong><span>{{ item.ocrProvider || 'OCR' }} · {{ item.recognitionStatus || '待确认' }}</span><p v-if="item.ocrRawText">OCR 原文:{{ item.ocrRawText }}</p><p v-if="item.adjustmentReason">调整原因:{{ item.adjustmentReason }}</p></div>
<div><span>原始</span><strong>{{ formatNumber(item.ocrWeightValue, ` ${item.ocrWeightUnit || ''}`) }}</strong></div>
<div><span>调整后</span><strong>{{ formatNumber(item.adjustedWeightValue, ` ${item.adjustedWeightUnit || ''}`) }}</strong></div>
<div><span>最终</span><strong>{{ formatNumber(item.finalWeightKg, ' kg') }}</strong></div>
<div><span>置信度</span><strong>{{ item.confidence === null ? '-' : `${Math.round(item.confidence * 100)}%` }}</strong></div>
</div>
<el-empty v-if="!detail.weightResults.length" description="暂无称重图片识别结果" :image-size="48" />
</div>
</div>
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
<div><strong>审核结论</strong><span v-if="isPending">通过后将自动完成计费、费用发布和入库。</span><span v-else-if="isRejected">当前测量已驳回,可修正后重新提交,或按权限申请异常放行。</span><span v-else>当前记录不在可操作的测量审核状态。</span></div>
<div class="action-buttons">
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并完成入库</el-button>
<el-button v-if="isRejected && canRelease" type="warning" plain :loading="actionLoading === 'release'" @click="openReasonDialog('release')"><ShieldAlert :size="16" />异常放行</el-button>
</div>
</div>
<div class="review-section history-section">
<div class="section-heading"><div><h3><History :size="17" />审核历史</h3><p>每次审核均保留操作人、原因和放行开关快照。</p></div></div>
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
<ol v-else class="history-list">
<li v-for="item in history" :key="item.id">
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }} · {{ item.allowRejectedConfig ? '放行开关已开启' : '放行开关未开启' }}</span></div>
</li>
</ol>
</div>
</template>
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="520px" destroy-on-close>
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
<template #footer><el-button :disabled="Boolean(actionLoading)" @click="reasonDialog = null">取消</el-button><el-button :type="reasonDialog === 'reject' ? 'danger' : 'warning'" :loading="Boolean(actionLoading)" @click="submitReason">确认{{ reasonDialog === 'reject' ? '驳回' : '放行' }}</el-button></template>
</el-dialog>
</section>
</template>
<style scoped>
.review-panel { display: grid; gap: 20px; color: var(--cc-text); }
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
.review-header h2 { margin: 0 0 5px; font-size: 22px; line-height: 1.25; }
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.comparison-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.comparison-card { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); }
.comparison-card strong { display: block; margin: 8px 0 12px; font-size: 21px; line-height: 1.2; overflow-wrap: anywhere; }
.comparison-label { color: var(--cc-muted); font-size: 12px; font-weight: 700; }
dl { display: grid; gap: 7px; margin: 0; }.comparison-card dl div { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }.comparison-card dt { color: var(--cc-muted); }.comparison-card dd { margin: 0; font-weight: 600; text-align: right; }
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.measurement-note { overflow-wrap: anywhere; }.measurement-note strong, .measurement-note span { display: block; }.measurement-note strong { color: var(--el-color-warning-dark-2); }.measurement-note strong + span { margin-top: 4px; color: var(--cc-muted); }
.weight-list { display: grid; gap: 8px; }.weight-row { display: grid; grid-template-columns: minmax(190px, 1.8fr) repeat(4, minmax(80px, 1fr)); gap: 12px; align-items: center; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); }.weight-row > div { min-width: 0; }.weight-row strong, .weight-row span, .weight-row p { display: block; overflow-wrap: anywhere; }.weight-row span { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }.weight-row p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.45; }.weight-row > div:not(:first-child) { text-align: right; }
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.comparison-grid { grid-template-columns: 1fr; }.weight-row { grid-template-columns: 1fr 1fr; }.weight-row > div:not(:first-child) { text-align: left; }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.comparison-card { padding: 13px; }.weight-row { grid-template-columns: 1fr; }.review-actions { padding: 13px; } }
</style>