2026-08-04 21:01:39 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-08-18 11:33:01 +08:00
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
2026-08-04 21:01:39 +08:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
2026-08-18 11:33:01 +08:00
|
|
|
|
import { Check, Pencil, RotateCcw } from '@lucide/vue'
|
2026-08-04 21:01:39 +08:00
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
2026-08-14 19:59:32 +08:00
|
|
|
|
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
2026-08-18 11:33:01 +08:00
|
|
|
|
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
|
|
|
|
|
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
|
2026-08-14 19:59:32 +08:00
|
|
|
|
import type { MeasurementReviewDetail, MeasurementReviewRecord } from './types'
|
2026-08-04 21:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
customerId?: string
|
|
|
|
|
|
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
|
|
|
|
|
|
tenantId?: string
|
|
|
|
|
|
inboundId: string
|
2026-08-18 11:41:14 +08:00
|
|
|
|
customerName?: string | null
|
|
|
|
|
|
channelName?: string | null
|
|
|
|
|
|
channelCode?: string | null
|
2026-08-04 21:01:39 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-08-18 11:33:01 +08:00
|
|
|
|
const emit = defineEmits<{ changed: [] }>()
|
2026-08-04 21:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-18 11:33:01 +08:00
|
|
|
|
const correctionMode = ref(false)
|
2026-08-04 21:01:39 +08:00
|
|
|
|
const requestSerial = ref(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 isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
|
2026-08-11 19:35:28 +08:00
|
|
|
|
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
2026-08-18 11:41:14 +08:00
|
|
|
|
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() || '渠道未填写')
|
2026-08-04 21:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
function formatDate(value: string | null | undefined) {
|
2026-08-18 11:33:01 +08:00
|
|
|
|
return value ? value.replace('T', ' ') : '—'
|
2026-08-04 21:01:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resultLabel(value: string | null | undefined) {
|
|
|
|
|
|
if (value === 'APPROVED') return '审核通过'
|
|
|
|
|
|
if (value === 'REJECTED') return '审核驳回'
|
2026-08-11 19:35:28 +08:00
|
|
|
|
return '未知结果'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 21:01:39 +08:00
|
|
|
|
function isConflict(error: unknown) {
|
|
|
|
|
|
const details = getApiErrorDetails(error)
|
2026-08-14 17:38:06 +08:00
|
|
|
|
return details.message.includes('版本已变化') || details.message.includes('并发修改')
|
2026-08-04 21:01:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-08-18 11:33:01 +08:00
|
|
|
|
history.value = (await listMeasurementReviewHistory(customerId.value, props.inboundId)).records
|
2026-08-04 21:01:39 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
history.value = []
|
|
|
|
|
|
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
historyLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function reload() {
|
|
|
|
|
|
await Promise.all([loadReview(), loadHistory()])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 11:33:01 +08:00
|
|
|
|
async function handleWeightChanged() {
|
|
|
|
|
|
await loadReview()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 21:01:39 +08:00
|
|
|
|
async function confirmApprove() {
|
|
|
|
|
|
if (!detail.value || !isPending.value || !canReview.value) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm(
|
2026-08-14 14:27:03 +08:00
|
|
|
|
'审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?',
|
2026-08-04 21:01:39 +08:00
|
|
|
|
'确认测量审核通过',
|
2026-08-14 14:27:03 +08:00
|
|
|
|
{ confirmButtonText: '通过并进入待分柜', cancelButtonText: '返回', type: 'warning' },
|
2026-08-04 21:01:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
actionLoading.value = 'approve'
|
|
|
|
|
|
try {
|
2026-08-14 14:27:03 +08:00
|
|
|
|
const result = await approveMeasurement(customerId.value, props.inboundId, {
|
2026-08-04 21:01:39 +08:00
|
|
|
|
measurementId: detail.value.measurementId,
|
2026-08-13 20:00:38 +08:00
|
|
|
|
expectedVersion: detail.value.inboundVersion,
|
2026-08-04 21:01:39 +08:00
|
|
|
|
})
|
2026-08-18 11:33:01 +08:00
|
|
|
|
if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
|
|
|
|
|
|
else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
|
2026-08-04 21:01:39 +08:00
|
|
|
|
await reload()
|
|
|
|
|
|
emit('changed')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
|
|
|
|
|
|
if (isConflict(error)) await reload()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
actionLoading.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(() => [customerId.value, props.inboundId], () => {
|
2026-08-18 11:33:01 +08:00
|
|
|
|
correctionMode.value = false
|
2026-08-04 21:01:39 +08:00
|
|
|
|
if (customerId.value && props.inboundId) void reload()
|
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
|
|
|
|
|
|
})
|
2026-08-12 16:31:46 +08:00
|
|
|
|
|
2026-08-18 11:33:01 +08:00
|
|
|
|
defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
2026-08-04 21:01:39 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<section class="review-panel" aria-labelledby="measurement-review-title">
|
2026-08-18 11:33:01 +08:00
|
|
|
|
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
|
|
|
|
|
|
<div class="review-toolbar" aria-label="测量审核操作">
|
2026-08-18 11:41:14 +08:00
|
|
|
|
<div class="review-toolbar__status">
|
|
|
|
|
|
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
|
|
|
|
|
|
<span v-if="isPending">通过后进入待分柜</span>
|
|
|
|
|
|
<span v-else-if="isRejected">历史记录为审核驳回,仅供查看</span>
|
|
|
|
|
|
<span v-else>当前记录不可审核</span>
|
|
|
|
|
|
<span v-if="detail" class="review-toolbar__context">客户:{{ displayCustomerName }} · 渠道:{{ displayChannelName }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="review-toolbar__actions">
|
|
|
|
|
|
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
|
|
|
|
|
|
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
|
|
|
|
|
|
<el-button :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
|
2026-08-14 19:59:32 +08:00
|
|
|
|
</div>
|
2026-08-18 11:33:01 +08:00
|
|
|
|
</div>
|
2026-08-04 21:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
<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">
|
2026-08-18 11:33:01 +08:00
|
|
|
|
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
|
2026-08-04 21:01:39 +08:00
|
|
|
|
|
2026-08-18 11:33:01 +08:00
|
|
|
|
<WeightOcrPanel
|
|
|
|
|
|
review-mode
|
|
|
|
|
|
:correction-mode="correctionMode"
|
|
|
|
|
|
:customer-id="customerId"
|
|
|
|
|
|
:inbound-id="inboundId"
|
|
|
|
|
|
@changed="handleWeightChanged"
|
|
|
|
|
|
/>
|
2026-08-04 21:01:39 +08:00
|
|
|
|
|
2026-08-14 19:59:32 +08:00
|
|
|
|
<el-collapse class="review-history">
|
|
|
|
|
|
<el-collapse-item name="history" :title="`审核历史(${history.length})`">
|
|
|
|
|
|
<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 || '系统' }}</span></div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ol>
|
|
|
|
|
|
</el-collapse-item>
|
|
|
|
|
|
</el-collapse>
|
2026-08-04 21:01:39 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-14 19:59:32 +08:00
|
|
|
|
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
|
2026-08-18 11:41:14 +08:00
|
|
|
|
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin: -20px -24px 0; padding: 14px 24px; border-bottom: 1px solid var(--cc-border); background: #fff; }
|
2026-08-18 11:33:01 +08:00
|
|
|
|
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
|
|
|
|
.review-toolbar__status > span { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.review-toolbar__actions { justify-content: flex-end; }
|
|
|
|
|
|
.review-toolbar__actions :deep(.el-button) { margin-left: 0; }
|
2026-08-18 11:41:14 +08:00
|
|
|
|
.review-toolbar__status > .review-toolbar__context { padding-left: 8px; border-left: 1px solid var(--cc-border); font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
|
2026-08-04 21:01:39 +08:00
|
|
|
|
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
2026-08-14 19:59:32 +08:00
|
|
|
|
.review-history { border-top: 1px solid var(--cc-border); border-bottom: 0; }
|
|
|
|
|
|
.review-history :deep(.el-collapse-item__header) { height: 42px; color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.review-history :deep(.el-collapse-item__wrap) { border-bottom: 0; }
|
2026-08-18 11:33:01 +08:00
|
|
|
|
.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; }
|
|
|
|
|
|
@media (max-width: 720px) {
|
2026-08-18 11:41:14 +08:00
|
|
|
|
.review-toolbar { align-items: stretch; flex-direction: column; margin: -14px -14px 0; padding: 12px 14px; }
|
|
|
|
|
|
.review-toolbar__status > .review-toolbar__context { width: 100%; padding-top: 8px; padding-left: 0; border-top: 1px solid var(--cc-border); border-left: 0; }
|
2026-08-18 11:33:01 +08:00
|
|
|
|
.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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 360px) {
|
|
|
|
|
|
.review-toolbar { margin: -10px -10px 0; padding: 10px; }
|
|
|
|
|
|
}
|
2026-08-04 21:01:39 +08:00
|
|
|
|
</style>
|