349 lines
21 KiB
Vue
349 lines
21 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import '@/styles/adminWorkbench.css'
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Check, Pencil, RotateCcw } 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 AppDialog from '@/shared/components/AppDialog.vue'
|
||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||
import { downloadInboundMeasurementImage } from '../measurement/api'
|
||
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
|
||
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
||
import { approveMeasurement, getMeasurementReviewDetail } from './api'
|
||
import type { MeasurementReviewDetail } from './types'
|
||
|
||
const props = withDefaults(defineProps<{
|
||
customerId?: string
|
||
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
|
||
tenantId?: string
|
||
inboundId: string
|
||
fulfillmentType?: string | null
|
||
customerName?: string | null
|
||
channelName?: string | null
|
||
channelCode?: string | null
|
||
postApproveLoading?: boolean
|
||
approvalCompleted?: boolean
|
||
showActions?: boolean
|
||
}>(), { showActions: true })
|
||
|
||
const emit = defineEmits<{ changed: [] }>()
|
||
|
||
const access = useAccessPreviewStore()
|
||
const detail = ref<MeasurementReviewDetail | null>(null)
|
||
const loading = ref(false)
|
||
const actionLoading = ref('')
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
const correctionMode = ref(false)
|
||
const requestSerial = ref(0)
|
||
const imagePreviewOpen = ref(false)
|
||
const imagePreviewTitle = ref('测量图片')
|
||
const imagePreviewUrl = ref('')
|
||
const imagePreviewLoading = ref(false)
|
||
const imagePreviewError = ref<ApiErrorDetails | null>(null)
|
||
let imagePreviewRequestSerial = 0
|
||
|
||
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
|
||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
|
||
const approvalTargetLabel = computed(() => props.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '待一件代发组托' : '待分柜')
|
||
const approvalButtonLabel = computed(() => '通过')
|
||
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
|
||
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
||
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
|
||
const displayChannelName = computed(() => detail.value?.channelName?.trim() || detail.value?.channelCode?.trim() || props.channelName?.trim() || props.channelCode?.trim() || '渠道未填写')
|
||
const measurementCount = computed(() => detail.value?.items.length || 0)
|
||
const weightCount = computed(() => detail.value?.weightResults.length || 0)
|
||
|
||
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
|
||
if (value === null || value === undefined || !Number.isFinite(value)) return '—'
|
||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits, minimumFractionDigits: 0 }).format(value)
|
||
}
|
||
|
||
function formatDifference(actual: number | null | undefined, declared: number | null | undefined, maximumFractionDigits = 3, unit = '') {
|
||
if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared)) return '—'
|
||
const difference = actual - declared
|
||
if (difference === 0) return '一致'
|
||
const sign = difference > 0 ? '+' : ''
|
||
return `差异 ${sign}${formatNumber(difference, maximumFractionDigits)}${unit}`
|
||
}
|
||
|
||
function formatDate(value: string | null | undefined) {
|
||
return value ? value.replace('T', ' ') : '—'
|
||
}
|
||
|
||
function releaseImagePreview() {
|
||
imagePreviewRequestSerial += 1
|
||
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
|
||
imagePreviewUrl.value = ''
|
||
imagePreviewLoading.value = false
|
||
imagePreviewError.value = null
|
||
}
|
||
|
||
async function openImagePreview(fileId: string | null, itemLabel: string) {
|
||
if (!fileId) return
|
||
releaseImagePreview()
|
||
const serial = ++imagePreviewRequestSerial
|
||
imagePreviewTitle.value = `测量图片 · ${itemLabel}`
|
||
imagePreviewOpen.value = true
|
||
imagePreviewLoading.value = true
|
||
try {
|
||
const blob = await downloadInboundMeasurementImage(customerId.value, props.inboundId, fileId)
|
||
if (serial !== imagePreviewRequestSerial) return
|
||
imagePreviewUrl.value = URL.createObjectURL(blob)
|
||
} catch (error) {
|
||
if (serial !== imagePreviewRequestSerial) return
|
||
imagePreviewError.value = getApiErrorDetails(error, '测量原图读取失败,请稍后重试')
|
||
} finally {
|
||
if (serial === imagePreviewRequestSerial) imagePreviewLoading.value = false
|
||
}
|
||
}
|
||
|
||
function isConflict(error: unknown) {
|
||
const details = getApiErrorDetails(error)
|
||
return details.message.includes('版本已变化') || details.message.includes('并发修改')
|
||
}
|
||
|
||
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 reload() {
|
||
await loadReview()
|
||
}
|
||
|
||
async function handleWeightChanged() {
|
||
await loadReview()
|
||
}
|
||
|
||
function enableWeightCorrection() {
|
||
correctionMode.value = true
|
||
}
|
||
|
||
async function confirmApprove() {
|
||
if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'审核通过后,入库单将进入待分柜',
|
||
'确认测量',
|
||
{ customClass: 'admin-density-confirm', confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
actionLoading.value = 'approve'
|
||
try {
|
||
const result = await approveMeasurement(customerId.value, props.inboundId, {
|
||
measurementId: detail.value.measurementId,
|
||
expectedVersion: detail.value.inboundVersion,
|
||
})
|
||
const resultStatusLabel = inboundStatusLabel(result.processStatus, inboundProcessStatusLabels)
|
||
if (result.initialPricingSucceeded) ElMessage.success(`审核已通过,入库单已进入${resultStatusLabel},费用已完成初算`)
|
||
else ElMessage.warning(`审核已通过,入库单已进入${resultStatusLabel};费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
|
||
emit('changed')
|
||
} catch (error) {
|
||
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
|
||
if (isConflict(error)) await reload()
|
||
} finally {
|
||
actionLoading.value = ''
|
||
}
|
||
}
|
||
|
||
watch(() => [customerId.value, props.inboundId], () => {
|
||
correctionMode.value = false
|
||
if (customerId.value && props.inboundId) void reload()
|
||
}, { immediate: true })
|
||
|
||
onMounted(() => {
|
||
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
|
||
})
|
||
onBeforeUnmount(releaseImagePreview)
|
||
|
||
defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPending, canReview, actionLoading })
|
||
</script>
|
||
|
||
<template>
|
||
<section class="review-panel admin-density density-dialog-sections admin-workbench" aria-labelledby="measurement-review-title">
|
||
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
|
||
<div class="review-toolbar" aria-label="测量审核操作">
|
||
<div v-if="showActions" class="review-toolbar__actions">
|
||
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
|
||
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
|
||
</div>
|
||
<div class="review-toolbar__status">
|
||
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
|
||
<span v-if="isPending">通过后进入{{ approvalTargetLabel }}</span>
|
||
<span v-else-if="isRejected">历史记录为审核驳回,仅供查看</span>
|
||
<span v-else>当前记录不可审核</span>
|
||
<span v-if="detail" class="review-toolbar__context">客户:{{ displayCustomerName }} · 渠道:{{ displayChannelName }}</span>
|
||
</div>
|
||
<el-button class="review-refresh" :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
|
||
</div>
|
||
|
||
<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">
|
||
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
|
||
|
||
<section class="review-data density-dialog-section" aria-labelledby="review-data-title">
|
||
<header class="review-section-heading density-dialog-section__title"><h3 id="review-data-title">审核数据总览</h3></header>
|
||
<div class="workbench-evidence"><table class="review-comparison density-table" aria-label="客户申报与平台实测对比">
|
||
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
|
||
<tbody>
|
||
<tr><th scope="row">箱数</th><td>{{ formatNumber(detail.originalPackageCount, 0) }}</td><td>{{ formatNumber(detail.actualPackageCount, 0) }}</td><td>{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</td></tr>
|
||
<tr><th scope="row">毛重(kg)</th><td>{{ formatNumber(detail.originalGrossWeightKg) }}</td><td>{{ formatNumber(detail.actualGrossWeightKg) }}</td><td>{{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</td></tr>
|
||
<tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 4) }}</td><td>{{ formatNumber(detail.actualVolumeCbm, 4) }}</td><td>{{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³') }}</td></tr>
|
||
</tbody>
|
||
</table></div>
|
||
<div class="review-extra-facts"><span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span><span>体积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span></div>
|
||
</section>
|
||
|
||
<section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title">
|
||
<header class="review-section-heading density-dialog-section__title">
|
||
<div>
|
||
<h3 id="review-items-title">箱规测量明细</h3>
|
||
</div>
|
||
<span>{{ measurementCount }} 条</span>
|
||
</header>
|
||
<div v-if="detail.items.length" class="review-table-scroll">
|
||
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
|
||
<el-table-column type="index" label="#" width="44" align="center" />
|
||
<el-table-column label="箱号" width="160" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="测量图片" width="98" align="center">
|
||
<template #default="{ row }">
|
||
<MeasurementEvidenceThumbnail
|
||
:customer-id="customerId"
|
||
:inbound-id="inboundId"
|
||
:file-id="row.intelligentImageFileId"
|
||
:alt="`箱规 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的测量图片`"
|
||
empty-text="无图片"
|
||
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || '箱规编码待补充')"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="尺寸 长×宽×高(cm)" min-width="200" align="center">
|
||
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="packageCount" label="箱数" width="54" align="right">
|
||
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="单箱体积(m³)" width="106" align="right">
|
||
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 4) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="总体积(m³)" width="96" align="right">
|
||
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 4) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="体积重(kg)" width="96" align="right">
|
||
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="测量时间" width="190" class-name="review-time-cell">
|
||
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="暂无箱规测量明细" :image-size="48" />
|
||
</section>
|
||
|
||
<section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title">
|
||
<header class="review-section-heading density-dialog-section__title"><h3 id="review-weight-title">称重明细</h3><span>{{ weightCount }} 条</span></header>
|
||
<div v-if="weightCount" class="review-table-scroll">
|
||
<el-table :data="detail.weightResults" border stripe table-layout="fixed" class="review-table review-weight-table">
|
||
<el-table-column type="index" label="#" width="44" align="center" />
|
||
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openImagePreview(row.fileId, row.palletCode || '托盘')" /></template></el-table-column>
|
||
<el-table-column prop="palletCode" label="托盘编码" min-width="120" />
|
||
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
|
||
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
|
||
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
|
||
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
|
||
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
|
||
<el-table-column label="称重时间" min-width="150"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="暂无称重明细" :image-size="48" />
|
||
</section>
|
||
|
||
<WeightOcrPanel
|
||
class="density-dialog-section"
|
||
data-tone="source"
|
||
review-mode
|
||
table-mode
|
||
:correction-mode="correctionMode"
|
||
:customer-id="customerId"
|
||
:inbound-id="inboundId"
|
||
@changed="handleWeightChanged"
|
||
:class="{ 'review-weight-editor--hidden': !correctionMode }"
|
||
/>
|
||
</template>
|
||
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" fullscreenable :show-footer="false" :close-on-click-modal="true" destroy-on-close append-to-body @closed="releaseImagePreview">
|
||
<div class="review-image-preview">
|
||
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
|
||
<div v-else-if="imagePreviewError" class="review-image-preview__error">
|
||
<span>{{ imagePreviewError.message }}</span>
|
||
<ApiErrorMeta :details="imagePreviewError" />
|
||
</div>
|
||
<img v-else-if="imagePreviewUrl" :src="imagePreviewUrl" alt="当前箱规测量原图" />
|
||
<el-empty v-else description="暂无可预览图片" />
|
||
</div>
|
||
</AppDialog>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.review-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
|
||
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); position: sticky; top: 0; z-index: 3; flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
||
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
|
||
.review-toolbar__status > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||
.review-toolbar__actions { justify-content: flex-end; }
|
||
.review-toolbar__actions :deep(.el-button) { margin-left: 0; }
|
||
.review-toolbar__status > .review-toolbar__context { padding-left: var(--admin-space-3, 8px); border-left: 1px solid var(--workbench-border); font-size: var(--admin-font-body, 13px); line-height: 1.55; overflow-wrap: anywhere; }
|
||
.review-alert-title { display: inline-flex; align-items: center; gap: var(--admin-space-3, 8px); flex-wrap: wrap; }
|
||
.review-data, .review-items { min-width: 0; }
|
||
.review-section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); margin-bottom: var(--admin-space-3, 8px); }
|
||
.review-section-heading > div { min-width: 0; }
|
||
.review-section-heading h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); line-height: 1.35; }
|
||
.review-section-heading > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); white-space: nowrap; }
|
||
.review-comparison { table-layout: fixed; }
|
||
.review-extra-facts { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-5, 12px); margin-top: var(--admin-space-3, 8px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||
.review-extra-facts strong { color: var(--workbench-text); }
|
||
.review-table-scroll { width: 100%; min-width: 0; overflow: hidden; }
|
||
.review-table { width: 100%; }
|
||
.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }
|
||
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: var(--admin-radius, 4px); }
|
||
.review-weight-value { color: var(--el-color-success-dark-2); font-weight: 600; }
|
||
.review-location { white-space: nowrap; }
|
||
.review-weight-editor--hidden { display: none; }
|
||
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
|
||
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
|
||
.review-image-preview__error { display: grid; justify-items: center; gap: var(--admin-space-3, 8px); color: var(--el-color-danger); }
|
||
@media (max-width: 720px) {
|
||
.review-toolbar { align-items: stretch; flex-direction: column; }
|
||
.review-toolbar__status > .review-toolbar__context { width: 100%; padding-top: 8px; padding-left: 0; border-top: 1px solid var(--workbench-border); border-left: 0; }
|
||
.review-toolbar__actions { display: grid; width: 100%; grid-template-columns: 1fr; justify-content: stretch; }
|
||
.review-toolbar__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||
.review-section-heading { align-items: stretch; flex-direction: column; }
|
||
}
|
||
</style>
|