优化
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
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/components/InboundWorkflowWorkspace.vue'), 'utf8')
|
||||
|
||||
describe('InboundWorkflowWorkspace', () => {
|
||||
it('assembles every phase-two inbound domain behind one server-backed workspace', () => {
|
||||
expect(source).toContain('InboundSummaryPanel')
|
||||
expect(source).toContain('InboundHistoryPanel')
|
||||
expect(source).toContain('InboundMeasurementPanel')
|
||||
expect(source).toContain('WeightOcrPanel')
|
||||
expect(source).toContain('MeasurementReviewPanel')
|
||||
expect(source).toContain('InboundFeePanel')
|
||||
expect(source).toContain('getPlatformInbound')
|
||||
expect(source).toContain('StatusBadge')
|
||||
expect(source).toContain('label="基本信息"')
|
||||
expect(source).toContain('label="货物与测量"')
|
||||
expect(source).toContain('label="测量审核"')
|
||||
expect(source).toContain('label="费用快照"')
|
||||
expect(source).toContain('label="托盘与记录"')
|
||||
})
|
||||
|
||||
it('uses the established first-leg detail density without nested framed surfaces', () => {
|
||||
expect(source).toContain('padding: 20px 24px')
|
||||
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
|
||||
expect(source).not.toContain('border-radius: 10px')
|
||||
expect(source).not.toContain('<el-tag')
|
||||
})
|
||||
|
||||
it('refreshes the aggregate and remounts domains after manual or child refresh', () => {
|
||||
expect(source).toContain('function handleDomainChanged()')
|
||||
expect(source).toContain('async function refreshWorkspace()')
|
||||
expect(source).toContain('revision.value += 1')
|
||||
expect(source).toContain('void refreshWorkspace()')
|
||||
expect(source).toContain('@click="refreshWorkspace"')
|
||||
expect(source).toContain('@changed="handleDomainChanged"')
|
||||
})
|
||||
|
||||
it('uses customer context in the page layer and maps it to compatible domain APIs', () => {
|
||||
expect(source).toContain('customerId: string')
|
||||
expect(source).toContain('getPlatformInbound(props.customerId, props.inboundId)')
|
||||
expect(source).toContain(':tenant-id="customerId"')
|
||||
expect(source).toContain('缺少所属企业信息')
|
||||
expect(source).not.toContain('请选择企业')
|
||||
expect(source).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('does not mount the review domain without its required measurement data permission', () => {
|
||||
expect(source).toContain("access.can('platform:firstleg:inbound:measure:view')")
|
||||
expect(source).toContain('v-if="canViewMeasurement"')
|
||||
expect(source).toContain('无测量审核查看权限')
|
||||
})
|
||||
|
||||
it('keeps pallet and location results read-only in the overview', () => {
|
||||
expect(source).toContain(':pallet-locations="detail.palletLocations"')
|
||||
expect(source).toContain('label="托盘与记录"')
|
||||
expect(source).not.toContain('reserveLocation')
|
||||
expect(source).not.toContain('allocatePallet')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { RefreshCw } 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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getPlatformInbound } from '../core/api'
|
||||
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
|
||||
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
|
||||
import type { InboundOrderDetail } from '../core/types'
|
||||
import { InboundMeasurementPanel } from '../measurement'
|
||||
import { WeightOcrPanel } from '../weight'
|
||||
import { MeasurementReviewPanel } from '../review'
|
||||
import { InboundFeePanel } from '../fee'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const activeTab = ref('overview')
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InboundOrderDetail | null>(null)
|
||||
const revision = ref(0)
|
||||
let requestSerial = 0
|
||||
let disposed = false
|
||||
|
||||
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
|
||||
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const statusLabel = computed(() => {
|
||||
const status = detail.value?.summary.processStatus || ''
|
||||
return ({
|
||||
WAIT_MEASURE: '待测量',
|
||||
MEASURING: '测量中',
|
||||
MEASURE_PENDING: '待审核',
|
||||
MEASURE_REJECTED: '测量已驳回',
|
||||
WAIT_PRICING: '待计费',
|
||||
FEE_CONFIRMING: '费用确认中',
|
||||
INBOUNDING: '入库处理中',
|
||||
INBOUNDED: '已入库',
|
||||
VOIDED: '已作废',
|
||||
} as Record<string, string>)[status] || status || '未知状态'
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
const serial = ++requestSerial
|
||||
if (!props.customerId || !props.inboundId) {
|
||||
detail.value = null
|
||||
loadError.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const response = await getPlatformInbound(props.customerId, props.inboundId)
|
||||
if (disposed || serial !== requestSerial) return
|
||||
detail.value = response
|
||||
} catch (error) {
|
||||
if (disposed || serial !== requestSerial) return
|
||||
detail.value = null
|
||||
loadError.value = getApiErrorDetails(error, '入库单详情加载失败')
|
||||
} finally {
|
||||
if (!disposed && serial === requestSerial) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWorkspace() {
|
||||
revision.value += 1
|
||||
await loadDetail()
|
||||
}
|
||||
|
||||
function handleDomainChanged() {
|
||||
void refreshWorkspace()
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.inboundId],
|
||||
() => {
|
||||
activeTab.value = 'overview'
|
||||
detail.value = null
|
||||
loadError.value = null
|
||||
revision.value += 1
|
||||
void loadDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
requestSerial += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-workspace" :aria-busy="loading">
|
||||
<header class="workspace-header">
|
||||
<div>
|
||||
<div class="workspace-title-row">
|
||||
<h2>入仓码:{{ title }}</h2>
|
||||
<StatusBadge v-if="detail" :status="statusLabel" />
|
||||
</div>
|
||||
<p>按基本信息、货物测量、审核、费用和托盘库位顺序处理,所有结果以服务端最新状态为准。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" :disabled="!customerId || !inboundId" @click="refreshWorkspace">
|
||||
<RefreshCw :size="16" />刷新
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="loadError"
|
||||
class="workspace-state"
|
||||
title="入库作业详情暂时无法加载"
|
||||
:message="loadError.message"
|
||||
@retry="loadDetail"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<div v-else-if="loading && !detail" class="workspace-state workspace-skeleton" aria-label="正在加载入库作业详情">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!detail" class="workspace-state" description="缺少所属企业信息,无法查看入库作业详情" />
|
||||
|
||||
<el-tabs v-else v-model="activeTab" class="workflow-tabs">
|
||||
<el-tab-pane label="基本信息" name="overview">
|
||||
<InboundSummaryPanel :summary="detail.summary" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="货物与测量" name="measurement" lazy>
|
||||
<div class="measurement-workflow">
|
||||
<InboundMeasurementPanel
|
||||
:key="`measurement-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
<WeightOcrPanel
|
||||
:key="`weight-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="测量审核" name="review" lazy>
|
||||
<MeasurementReviewPanel
|
||||
v-if="canViewMeasurement"
|
||||
:key="`review-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
title="无测量审核查看权限"
|
||||
description="当前账号缺少测量查看权限,无法读取审核所需的原单、箱规和称重数据。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="费用快照" name="fee" lazy>
|
||||
<InboundFeePanel
|
||||
:key="`fee-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="托盘与记录" name="records" lazy>
|
||||
<InboundHistoryPanel
|
||||
:pallet-locations="detail.palletLocations"
|
||||
:files="detail.files"
|
||||
:internal-notes="detail.internalNotes"
|
||||
:status-history="detail.statusHistory"
|
||||
:public-remark="detail.publicRemark"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-workspace { min-width: 0; }
|
||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
||||
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.workspace-state { margin: 24px; }
|
||||
.workspace-skeleton { padding: 0; }
|
||||
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
|
||||
.workflow-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
|
||||
.workflow-tabs :deep(.el-tabs__item) { min-height: 48px; }
|
||||
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
|
||||
@media (max-width: 720px) {
|
||||
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
|
||||
.workspace-header :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.workspace-state { margin: 16px; }
|
||||
.workflow-tabs { padding: 0 16px 16px; }
|
||||
.workflow-tabs :deep(.el-tabs__nav-wrap) { overflow-x: auto; }
|
||||
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding-inline: 14px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { InboundFileSummary, InboundInternalNote, InboundPalletLocation, InboundStatusHistory } from './types'
|
||||
|
||||
defineProps<{
|
||||
palletLocations: InboundPalletLocation[]
|
||||
files: InboundFileSummary[]
|
||||
internalNotes: InboundInternalNote[]
|
||||
statusHistory: InboundStatusHistory[]
|
||||
publicRemark: string | null
|
||||
}>()
|
||||
|
||||
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function number(value: number | null) { return value === null ? '—' : value.toFixed(3) }
|
||||
function date(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-panel">
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
|
||||
<div v-if="palletLocations.length" class="allocation-list">
|
||||
<div v-for="item in palletLocations" :key="`${item.allocationNo}-${item.palletId}-${item.locationId}`" class="allocation-row">
|
||||
<div><strong>{{ value(item.palletCode) }}</strong><span>分配 {{ value(item.allocationNo) }}</span></div>
|
||||
<div><span>{{ number(item.allocatedVolumeCbm) }} CBM</span><span>{{ value(item.allocatedPackageCount) }} 箱</span></div>
|
||||
<div class="allocation-location">{{ value(item.locationCode) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted-empty">暂无托盘库位分配记录</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>文件记录</h3><span>{{ files.length }} 个文件</span></div>
|
||||
<div v-if="files.length" class="simple-list"><div v-for="file in files" :key="`${file.fileId}-${file.fileVersion}`"><strong>{{ value(file.fileNameSnapshot) }}</strong><span>{{ value(file.fileType) }} · {{ date(file.uploadedAt) }}</span></div></div>
|
||||
<p v-else class="muted-empty">暂无文件记录</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>内部备注</h3><span>{{ internalNotes.length }} 条</span></div>
|
||||
<div v-if="internalNotes.length" class="simple-list"><div v-for="note in internalNotes" :key="String(note.id)"><strong>{{ value(note.createdByName || note.createdBy) }}</strong><span>{{ value(note.content) }} · {{ date(note.createdAt) }}</span></div></div>
|
||||
<p v-else class="muted-empty">暂无内部备注</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>状态历史</h3><span>{{ statusHistory.length }} 条</span></div>
|
||||
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ value(history.action) }}</strong><span>{{ value(history.fromStatus) }} → {{ value(history.toStatus) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
|
||||
<p v-else class="muted-empty">暂无状态历史</p>
|
||||
</section>
|
||||
<section v-if="publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history-panel { display: grid; gap: 22px; min-width: 0; }
|
||||
.history-section { min-width: 0; }
|
||||
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.section-heading h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
|
||||
.section-heading span { color: var(--cc-muted); font-size: 12px; }
|
||||
.allocation-list, .simple-list { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; }
|
||||
.allocation-row { display: grid; grid-template-columns: 1fr auto minmax(100px, 0.8fr); gap: 12px; align-items: center; padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
|
||||
.allocation-row:last-child, .simple-list > div:last-child { border-bottom: 0; }
|
||||
.allocation-row > div, .simple-list > div { min-width: 0; }
|
||||
.allocation-row span, .simple-list span { display: block; margin-top: 3px; color: var(--cc-muted); }
|
||||
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
|
||||
.simple-list > div { padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
|
||||
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
|
||||
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 8px; color: var(--cc-muted); font-size: 12px; text-align: center; }
|
||||
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
|
||||
.timeline-dot { width: 8px; height: 8px; margin-top: 5px; border: 2px solid var(--cc-primary); border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--cc-primary) 12%, transparent); }
|
||||
.timeline li > div { min-width: 0; padding-bottom: 12px; border-bottom: 1px solid var(--cc-border); }
|
||||
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
|
||||
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.remark-section p { margin: 0; padding: 12px; border-radius: 8px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
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/core/InboundSummaryPanel.vue'), 'utf8')
|
||||
|
||||
describe('InboundSummaryPanel', () => {
|
||||
it('organizes the aggregate detail into the prototype-aligned backend-backed sections', () => {
|
||||
expect(source).toContain('基本信息')
|
||||
expect(source).toContain('仓库与运输')
|
||||
expect(source).toContain('测量与计费')
|
||||
expect(source).toContain('入库资源')
|
||||
expect(source).toContain('summary.deliveryWarehouseName')
|
||||
expect(source).toContain('summary.chargeableWeightKg')
|
||||
expect(source).toContain('summary.locationCodes')
|
||||
expect(source).toContain('summary.notificationStatus')
|
||||
})
|
||||
|
||||
it('does not restore excluded receiving review or customer fee confirmation actions', () => {
|
||||
expect(source).not.toContain('接收审核')
|
||||
expect(source).not.toContain('客户确认费用')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { Box, MapPin, Scale, Warehouse } from '@lucide/vue'
|
||||
import type { InboundOrderSummary } from './types'
|
||||
|
||||
defineProps<{ summary: InboundOrderSummary }>()
|
||||
|
||||
const processLabels: Record<string, string> = {
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回',
|
||||
WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废',
|
||||
}
|
||||
|
||||
function processLabel(status: string) { return processLabels[status] || status || '未知状态' }
|
||||
function processType(status: string) {
|
||||
if (status === 'INBOUNDED') return 'success'
|
||||
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
|
||||
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function number(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
|
||||
function date(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function yesNo(value: boolean | null) { return value === null ? '—' : value ? '是' : '否' }
|
||||
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
|
||||
const feeLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
|
||||
const notificationLabels: Record<string, string> = { NOT_CREATED: '未创建', PENDING: '待发送', SUCCESS: '发送成功', FAILED: '发送失败' }
|
||||
function label(value: string | null, labels: Record<string, string>) { return value ? labels[value] || value : '—' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="summary-panel" aria-label="入库单概览">
|
||||
<div class="summary-heading">
|
||||
<div>
|
||||
<h3>{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
|
||||
<p class="summary-subtitle">订单 {{ value(summary.orderNo) }} · {{ value(summary.customerName) }}</p>
|
||||
</div>
|
||||
<el-tag :type="processType(summary.processStatus)" effect="light">{{ processLabel(summary.processStatus) }}</el-tag>
|
||||
</div>
|
||||
<p v-if="summary.holdFlag" class="hold-note" role="status">当前挂起:{{ summary.currentHoldReason || '待处理异常' }}</p>
|
||||
<div class="summary-sections">
|
||||
<section aria-labelledby="inbound-basic-heading">
|
||||
<h4 id="inbound-basic-heading">基本信息</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt>头程订单号</dt><dd>{{ value(summary.orderNo) }}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{ value(summary.orderStatusSnapshot) }}</dd></div>
|
||||
<div><dt>客户</dt><dd>{{ value(summary.customerName) }}</dd></div>
|
||||
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ value(summary.businessType) }}</dd></div>
|
||||
<div><dt>渠道</dt><dd>{{ value(summary.channelName || summary.channelCode) }}</dd></div>
|
||||
<div><dt>计税模式</dt><dd>{{ value(summary.taxMode) }}</dd></div>
|
||||
<div><dt>装载模式</dt><dd>{{ value(summary.containerMode) }}</dd></div>
|
||||
<div><dt>商检验</dt><dd>{{ yesNo(summary.customsInspection) }}</dd></div>
|
||||
<div><dt>创建时间</dt><dd>{{ date(summary.createdAt) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="inbound-route-heading">
|
||||
<h4 id="inbound-route-heading">仓库与运输</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt><Warehouse :size="14" />收货仓</dt><dd>{{ value(summary.warehouseName) }}</dd></div>
|
||||
<div><dt>交货仓</dt><dd>{{ value(summary.deliveryWarehouseName) }}</dd></div>
|
||||
<div><dt><MapPin :size="14" />目的仓</dt><dd>{{ value(summary.destinationName || summary.destinationCode) }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ value(summary.destinationType) }}</dd></div>
|
||||
<div><dt>集装箱号</dt><dd>{{ value(summary.containerNo) }}</dd></div>
|
||||
<div><dt>柜号</dt><dd>{{ value(summary.cabinetNo) }}</dd></div>
|
||||
<div><dt>船名 / 航次</dt><dd>{{ value(summary.vesselName) }} / {{ value(summary.voyageNo) }}</dd></div>
|
||||
<div><dt>配载状态</dt><dd>{{ value(summary.loadingStatus) }}</dd></div>
|
||||
<div><dt>预计到达</dt><dd>{{ date(summary.expectedArrivalAt) }}</dd></div>
|
||||
<div><dt>开船 / 到港</dt><dd>{{ date(summary.sailingAt) }} / {{ date(summary.portArrivalAt) }}</dd></div>
|
||||
<div><dt>快递公司</dt><dd>{{ value(summary.expressCompanyName || summary.expressCompanyCode) }}</dd></div>
|
||||
<div><dt>快递单号</dt><dd>{{ value(summary.expressTrackingNo) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="inbound-measure-heading">
|
||||
<h4 id="inbound-measure-heading">测量与计费</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt><Box :size="14" />总箱数</dt><dd>{{ value(summary.totalPackageCount) }} 箱</dd></div>
|
||||
<div><dt><Scale :size="14" />总毛重</dt><dd>{{ number(summary.actualGrossWeightKg) }} kg</dd></div>
|
||||
<div><dt>总净重</dt><dd>{{ number(summary.actualNetWeightKg) }} kg</dd></div>
|
||||
<div><dt>总体积</dt><dd>{{ number(summary.actualVolumeCbm) }} CBM</dd></div>
|
||||
<div><dt>总材积重</dt><dd>{{ number(summary.volumeWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费重量</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费体积</dt><dd>{{ number(summary.chargeableVolumeCbm) }} CBM</dd></div>
|
||||
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, measureReviewLabels) }}</dd></div>
|
||||
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, feeLabels) }}</dd></div>
|
||||
<div><dt>费用审核状态</dt><dd>{{ value(summary.feeReviewStatus) }}</dd></div>
|
||||
<div><dt>测量完成时间</dt><dd>{{ date(summary.measureCompletedAt) }}</dd></div>
|
||||
<div><dt>通知状态</dt><dd>{{ label(summary.notificationStatus, notificationLabels) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="inbound-storage-heading">
|
||||
<h4 id="inbound-storage-heading">入库资源</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt>总托盘数</dt><dd>{{ value(summary.palletCount) }}</dd></div>
|
||||
<div><dt>库位位置</dt><dd>{{ value(summary.locationCodes) }}</dd></div>
|
||||
<div><dt>入库完成时间</dt><dd>{{ date(summary.inboundedAt) }}</dd></div>
|
||||
<div><dt>最后更新时间</dt><dd>{{ date(summary.updatedAt) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-panel { min-width: 0; }
|
||||
.summary-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.3; }
|
||||
.summary-subtitle { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.summary-sections { display: grid; min-width: 0; gap: 22px; margin-top: 18px; }
|
||||
.summary-sections section { min-width: 0; }
|
||||
h4 { margin: 0 0 9px; color: var(--cc-text); font-size: 14px; line-height: 1.4; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.summary-grid > div { min-width: 0; min-height: 66px; padding: 11px 12px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
dt { display: flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
|
||||
dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.5; }
|
||||
.hold-note { margin: 16px 0 0; padding: 10px 12px; border: 1px solid #f5c26b; border-radius: 8px; background: #fffaeb; color: #93370d; font-size: 12px; line-height: 1.5; }
|
||||
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 480px) { .summary-heading { align-items: stretch; flex-direction: column; } .summary-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getPlatformInbound, listPlatformInbounds } from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('platform inbound query API', () => {
|
||||
beforeEach(() => vi.mocked(http.get).mockReset())
|
||||
|
||||
it('omits the backend tenant filter for an all-customer list query', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
|
||||
await listPlatformInbounds(undefined, { page: 1, size: 20 })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', { params: { page: 1, size: 20 } })
|
||||
})
|
||||
|
||||
it('uses the implemented non-v1 list path and normalizes page values and IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: {
|
||||
records: [{ id: 19001, orderId: 18001, customerId: 10001, totalPackageCount: '6', holdFlag: 'false' }],
|
||||
page: '1', size: '20', total: '1', pages: '1',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(listPlatformInbounds('10001', {
|
||||
page: 1, size: 20, tab: 'WAIT_MEASURE', inboundCode: 'RK', holdFlag: false,
|
||||
})).resolves.toMatchObject({
|
||||
records: [{ id: '19001', orderId: '18001', customerId: '10001', totalPackageCount: 6, holdFlag: false }],
|
||||
total: 1, pages: 1,
|
||||
})
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', {
|
||||
params: { tenantId: '10001', page: 1, size: 20, tab: 'WAIT_MEASURE', inboundCode: 'RK', holdFlag: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('loads a tenant-scoped aggregate detail and keeps nested IDs as strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: {
|
||||
summary: { id: 19001, orderId: 18001 }, publicRemark: null,
|
||||
palletLocations: [{ palletId: 21001, locationId: 22001 }], files: [{ fileId: 23001, uploadedBy: 2 }],
|
||||
internalNotes: [{ id: 24001, createdBy: 2 }], statusHistory: [{ operatorId: 2 }],
|
||||
},
|
||||
})
|
||||
|
||||
await expect(getPlatformInbound('10001', '19001')).resolves.toMatchObject({
|
||||
summary: { id: '19001', orderId: '18001' },
|
||||
palletLocations: [{ palletId: '21001', locationId: '22001' }],
|
||||
files: [{ fileId: '23001', uploadedBy: '2' }],
|
||||
internalNotes: [{ id: '24001', createdBy: '2' }],
|
||||
statusHistory: [{ operatorId: '2' }],
|
||||
})
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001', { params: { tenantId: '10001' } })
|
||||
})
|
||||
|
||||
it('passes the implemented order, process, measurement and fee review filters without renaming them', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
|
||||
await listPlatformInbounds('10001', {
|
||||
page: 1,
|
||||
size: 20,
|
||||
processStatus: 'MEASURE_PENDING',
|
||||
orderStatus: 'APPROVED',
|
||||
measureReviewStatus: 'PENDING',
|
||||
feeStatus: 'UNPRICED',
|
||||
feeReviewStatus: 'PENDING',
|
||||
warehouseId: '9007199254740993',
|
||||
deliveryWarehouseId: '9007199254740995',
|
||||
})
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', {
|
||||
params: {
|
||||
tenantId: '10001', page: 1, size: 20, processStatus: 'MEASURE_PENDING',
|
||||
orderStatus: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: 'PENDING',
|
||||
warehouseId: '9007199254740993', deliveryWarehouseId: '9007199254740995',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
InboundFileSummary,
|
||||
InboundInternalNote,
|
||||
InboundListQuery,
|
||||
InboundOrderDetail,
|
||||
InboundOrderSummary,
|
||||
InboundPageResult,
|
||||
InboundPalletLocation,
|
||||
InboundStatusHistory,
|
||||
} from './types'
|
||||
|
||||
const basePath = '/platform/inbounds'
|
||||
|
||||
function id(value: unknown): string | null {
|
||||
return value === null || value === undefined ? null : String(value)
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function integerValue(value: unknown): number | null {
|
||||
const parsed = numberValue(value)
|
||||
return parsed === null ? null : Math.trunc(parsed)
|
||||
}
|
||||
|
||||
function boolValue(value: unknown): boolean | null {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 'true' || value === 1 || value === '1') return true
|
||||
if (value === 'false' || value === 0 || value === '0') return false
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeInboundSummary(raw: InboundOrderSummary): InboundOrderSummary {
|
||||
return {
|
||||
...raw,
|
||||
id: String(raw.id),
|
||||
orderId: id(raw.orderId),
|
||||
customerId: id(raw.customerId),
|
||||
companyId: id(raw.companyId),
|
||||
warehouseId: id(raw.warehouseId),
|
||||
deliveryWarehouseId: id(raw.deliveryWarehouseId),
|
||||
destinationWarehouseId: id(raw.destinationWarehouseId),
|
||||
version: integerValue(raw.version),
|
||||
customsInspection: boolValue(raw.customsInspection),
|
||||
holdFlag: boolValue(raw.holdFlag),
|
||||
totalPackageCount: integerValue(raw.totalPackageCount),
|
||||
actualGrossWeightKg: numberValue(raw.actualGrossWeightKg),
|
||||
actualNetWeightKg: numberValue(raw.actualNetWeightKg),
|
||||
actualVolumeCbm: numberValue(raw.actualVolumeCbm),
|
||||
volumeWeightKg: numberValue(raw.volumeWeightKg),
|
||||
chargeableWeightKg: numberValue(raw.chargeableWeightKg),
|
||||
chargeableVolumeCbm: numberValue(raw.chargeableVolumeCbm),
|
||||
palletCount: integerValue(raw.palletCount),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePage(raw: InboundPageResult): InboundPageResult {
|
||||
return {
|
||||
records: (raw.records || []).map(normalizeInboundSummary),
|
||||
page: integerValue(raw.page) || 1,
|
||||
size: integerValue(raw.size) || 20,
|
||||
total: integerValue(raw.total) || 0,
|
||||
pages: integerValue(raw.pages) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePallet(raw: InboundPalletLocation): InboundPalletLocation {
|
||||
return {
|
||||
...raw,
|
||||
allocationNo: integerValue(raw.allocationNo),
|
||||
warehouseId: id(raw.warehouseId),
|
||||
palletId: id(raw.palletId),
|
||||
locationId: id(raw.locationId),
|
||||
palletCapacityCbm: numberValue(raw.palletCapacityCbm),
|
||||
allocatedVolumeCbm: numberValue(raw.allocatedVolumeCbm),
|
||||
allocatedPackageCount: integerValue(raw.allocatedPackageCount),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFile(raw: InboundFileSummary): InboundFileSummary {
|
||||
return { ...raw, fileId: id(raw.fileId), uploadedBy: id(raw.uploadedBy), fileVersion: integerValue(raw.fileVersion) }
|
||||
}
|
||||
|
||||
function normalizeNote(raw: InboundInternalNote): InboundInternalNote {
|
||||
return { ...raw, id: id(raw.id), createdBy: id(raw.createdBy) }
|
||||
}
|
||||
|
||||
function normalizeHistory(raw: InboundStatusHistory): InboundStatusHistory {
|
||||
return { ...raw, operatorId: id(raw.operatorId) }
|
||||
}
|
||||
|
||||
function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
|
||||
return {
|
||||
...raw,
|
||||
summary: normalizeInboundSummary(raw.summary),
|
||||
palletLocations: (raw.palletLocations || []).map(normalizePallet),
|
||||
files: (raw.files || []).map(normalizeFile),
|
||||
internalNotes: (raw.internalNotes || []).map(normalizeNote),
|
||||
statusHistory: (raw.statusHistory || []).map(normalizeHistory),
|
||||
}
|
||||
}
|
||||
|
||||
function queryParams(tenantId: string | undefined, query: InboundListQuery) {
|
||||
return Object.fromEntries(
|
||||
Object.entries({ tenantId, ...query }).filter(([, value]) => value !== undefined && value !== ''),
|
||||
)
|
||||
}
|
||||
|
||||
export async function listPlatformInbounds(tenantId: string | undefined, query: InboundListQuery) {
|
||||
const response = await http.get<ApiEnvelope<InboundPageResult>, ApiEnvelope<InboundPageResult>>(basePath, {
|
||||
params: queryParams(tenantId, query),
|
||||
})
|
||||
return normalizePage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getPlatformInbound(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<InboundOrderDetail>, ApiEnvelope<InboundOrderDetail>>(
|
||||
`${basePath}/${inboundId}`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return normalizeDetail(unwrapApiResponse(response))
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
export type InboundListTab = 'WAIT_MEASURE' | 'WAIT_REVIEW' | 'WAIT_FEE_SEND' | 'INBOUNDED'
|
||||
|
||||
export type InboundProcessStatus =
|
||||
| 'WAIT_MEASURE'
|
||||
| 'MEASURING'
|
||||
| 'MEASURE_PENDING'
|
||||
| 'MEASURE_REJECTED'
|
||||
| 'WAIT_PRICING'
|
||||
| 'FEE_CONFIRMING'
|
||||
| 'INBOUNDING'
|
||||
| 'INBOUNDED'
|
||||
| 'VOIDED'
|
||||
|
||||
export type InboundFeeStatus = 'UNPRICED' | 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'VOIDED' | string
|
||||
|
||||
export interface InboundOrderSummary {
|
||||
id: string
|
||||
orderId: string | null
|
||||
orderNo: string | null
|
||||
inboundCode: string | null
|
||||
processStatus: InboundProcessStatus | string
|
||||
orderStatusSnapshot: string | null
|
||||
measureReviewStatus: string | null
|
||||
feeStatus: InboundFeeStatus | null
|
||||
feeReviewStatus: string | null
|
||||
notificationStatus: string | null
|
||||
version: number | null
|
||||
expectedArrivalAt: string | null
|
||||
customerId: string | null
|
||||
customerName: string | null
|
||||
companyId: string | null
|
||||
companyName: string | null
|
||||
warehouseId: string | null
|
||||
warehouseName: string | null
|
||||
deliveryWarehouseId: string | null
|
||||
deliveryWarehouseName: string | null
|
||||
businessType: string | null
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
taxMode: string | null
|
||||
containerMode: string | null
|
||||
customsInspection: boolean | null
|
||||
destinationType: string | null
|
||||
destinationWarehouseId: string | null
|
||||
destinationCode: string | null
|
||||
destinationName: string | null
|
||||
holdFlag: boolean | null
|
||||
currentHoldReason: string | null
|
||||
totalPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualNetWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
chargeableWeightKg: number | null
|
||||
chargeableVolumeCbm: number | null
|
||||
palletCount: number | null
|
||||
locationCodes: string | null
|
||||
measureCompletedAt: string | null
|
||||
inboundedAt: string | null
|
||||
expressCompanyCode: string | null
|
||||
expressCompanyName: string | null
|
||||
expressTrackingNo: string | null
|
||||
loadingStatus: string | null
|
||||
containerNo: string | null
|
||||
cabinetNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
sailingAt: string | null
|
||||
portArrivalAt: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface InboundPalletLocation {
|
||||
allocationNo: number | null
|
||||
warehouseId: string | null
|
||||
palletId: string | null
|
||||
palletCode: string | null
|
||||
palletCapacityCbm: number | null
|
||||
allocatedVolumeCbm: number | null
|
||||
allocatedPackageCount: number | null
|
||||
locationId: string | null
|
||||
locationCode: string | null
|
||||
reservationNo: string | null
|
||||
allocationStatus: string | null
|
||||
}
|
||||
|
||||
export interface InboundFileSummary {
|
||||
fileId: string | null
|
||||
fileType: string | null
|
||||
fileNameSnapshot: string | null
|
||||
visibility: string | null
|
||||
fileVersion: number | null
|
||||
uploadedBy: string | null
|
||||
uploadedAt: string | null
|
||||
}
|
||||
|
||||
export interface InboundInternalNote {
|
||||
id: string | null
|
||||
content: string | null
|
||||
createdBy: string | null
|
||||
createdByName: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface InboundStatusHistory {
|
||||
fromStatus: string | null
|
||||
toStatus: string | null
|
||||
action: string | null
|
||||
reason: string | null
|
||||
operatorId: string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface InboundOrderDetail {
|
||||
summary: InboundOrderSummary
|
||||
publicRemark: string | null
|
||||
palletLocations: InboundPalletLocation[]
|
||||
files: InboundFileSummary[]
|
||||
internalNotes: InboundInternalNote[]
|
||||
statusHistory: InboundStatusHistory[]
|
||||
}
|
||||
|
||||
export interface InboundPageResult {
|
||||
records: InboundOrderSummary[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface InboundListQuery {
|
||||
page: number
|
||||
size: number
|
||||
tab?: InboundListTab
|
||||
inboundCode?: string
|
||||
orderNo?: string
|
||||
processStatus?: string
|
||||
orderStatus?: string
|
||||
measureReviewStatus?: string
|
||||
customerName?: string
|
||||
destinationCode?: string
|
||||
feeStatus?: string
|
||||
feeReviewStatus?: string
|
||||
customerId?: string
|
||||
holdFlag?: boolean
|
||||
companyId?: string
|
||||
warehouseId?: string
|
||||
deliveryWarehouseId?: string
|
||||
expectedArrivalFrom?: string
|
||||
expectedArrivalTo?: string
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
import type { InboundFeeSnapshot } from './types'
|
||||
import InboundFeePanel from './InboundFeePanel.vue'
|
||||
|
||||
vi.mock('./api', async () => {
|
||||
const actual = await vi.importActual<typeof import('./api')>('./api')
|
||||
return {
|
||||
...actual,
|
||||
getLatestInboundFee: vi.fn(),
|
||||
adjustPublishedInboundFee: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const snapshot: InboundFeeSnapshot = {
|
||||
feeId: '9007199254740993', feeNo: 'FBAF-88-2', feeVersion: 2, feeStatus: 'SENT',
|
||||
inboundId: '88', inboundCode: 'RK202608040088',
|
||||
chargeRuleId: '101', ruleCode: 'RULE-FBA-KG', chargeRuleVersion: 3,
|
||||
carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5,
|
||||
customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓',
|
||||
businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司',
|
||||
destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG',
|
||||
conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP',
|
||||
actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6,
|
||||
chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100,
|
||||
totalAmount: 1235.6, currency: 'CNY',
|
||||
rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null,
|
||||
calculatedAt: '2026-08-04T10:00:00', confirmedAt: '2026-08-04T10:01:00', sentAt: '2026-08-04T10:02:00',
|
||||
nextProcessStatus: 'INBOUNDED',
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function setupPermissions(permissions: string[]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return pinia
|
||||
}
|
||||
|
||||
function mountPanel(permissions = ['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) {
|
||||
const pinia = setupPermissions(permissions)
|
||||
return mount(InboundFeePanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '10001', inboundId: '88' },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('InboundFeePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue(snapshot)
|
||||
vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' })
|
||||
})
|
||||
|
||||
it('shows the immutable calculation basis, versions, amount, and timestamps', async () => {
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(document.body.textContent).toContain('对客费用总额 · CNY')
|
||||
expect(document.body.textContent).toContain('¥1,235.60')
|
||||
expect(document.body.textContent).toContain('基础运费小计 ¥1,135.60')
|
||||
expect(document.body.textContent).toContain('附加费 ¥100.00')
|
||||
expect(document.body.textContent).toContain('133.6 KG')
|
||||
expect(document.body.textContent).toContain('按重量计费')
|
||||
expect(document.body.textContent).toContain('¥8.50 / KG')
|
||||
expect(document.body.textContent).toContain('RULE-FBA-KG · V3')
|
||||
expect(document.body.textContent).toContain('RATE-LAX · V5')
|
||||
expect(document.body.textContent).toContain('发布给客户')
|
||||
expect(document.body.textContent).not.toContain('重新试算')
|
||||
expect(document.body.textContent).not.toContain('发送费用')
|
||||
})
|
||||
|
||||
it('follows the admin detail interaction pattern without list-only scaffolding', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('button[aria-label="刷新费用快照"]').classes()).toContain('is-circle')
|
||||
expect(wrapper.get('.version-status .el-tag').text()).toBe('已发布')
|
||||
expect(wrapper.find('.query-toolbar').exists()).toBe(false)
|
||||
expect(wrapper.find('.data-table').exists()).toBe(false)
|
||||
expect(wrapper.find('.surface-panel').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => {
|
||||
const pinia = setupPermissions(['firstleg:fba-pricing:view'])
|
||||
mount(InboundFeePanel, {
|
||||
props: { tenantId: 'legacy-10001', inboundId: '88' },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledWith('legacy-10001', '88')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('keeps the prototype-aligned detail order and renders long values with pending timestamps', async () => {
|
||||
const longRuleCode = `RULE-${'LONG-'.repeat(20)}`
|
||||
const longCarrierName = `船司${'超长名称'.repeat(20)}`
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({
|
||||
...snapshot,
|
||||
ruleCode: longRuleCode,
|
||||
carrierName: longCarrierName,
|
||||
confirmedAt: null,
|
||||
sentAt: null,
|
||||
})
|
||||
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
const content = wrapper.find('.fee-content').text()
|
||||
expect(wrapper.findAll('.fee-summary > div > span').map((node) => node.text())).toEqual([
|
||||
'对客费用总额 · CNY',
|
||||
'最终计费量',
|
||||
'快照版本',
|
||||
])
|
||||
expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([
|
||||
'实际重量',
|
||||
'实际体积',
|
||||
'折算量',
|
||||
'取大后的计费量',
|
||||
])
|
||||
expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([
|
||||
'客户计费规则',
|
||||
'船司 FBA 价格',
|
||||
])
|
||||
expect(content).toContain(longRuleCode)
|
||||
expect(content).toContain(longCarrierName)
|
||||
const pendingTimelineItems = wrapper.findAll('.fee-timeline li.pending')
|
||||
expect(pendingTimelineItems).toHaveLength(2)
|
||||
expect(pendingTimelineItems.map((item) => item.find('strong').text())).toEqual(['—', '—'])
|
||||
})
|
||||
|
||||
it('does not request or expose fees without the view permission', async () => {
|
||||
mountPanel([])
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('没有查看入库费用的权限')
|
||||
})
|
||||
|
||||
it('treats A0404 as a neutral not-yet-generated state', async () => {
|
||||
vi.mocked(getLatestInboundFee).mockRejectedValue(new ApiResponseError('费用快照不存在', 'A0404'))
|
||||
mountPanel(['firstleg:fba-pricing:view'])
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('尚未生成费用快照')
|
||||
expect(document.body.textContent).toContain('测量审核完成后')
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows adjustment only for authorized published inbound fees', async () => {
|
||||
mountPanel(['firstleg:fba-pricing:view'])
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-testid="adjust-fee-button"]')).toBeNull()
|
||||
|
||||
document.body.innerHTML = ''
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-testid="adjust-fee-button"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
|
||||
numbers[0]!.value = '1200'
|
||||
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[0]!.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
numbers[1]!.value = '80'
|
||||
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[1]!.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
|
||||
reason.value = '补充已确认的附加费用'
|
||||
reason.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
reason.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
|
||||
const confirm = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.includes('确认调整并生成新版本'))
|
||||
confirm?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(adjustPublishedInboundFee).toHaveBeenCalledWith(
|
||||
'10001',
|
||||
'88',
|
||||
{ subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费用' },
|
||||
expect.stringMatching(/^fee-adjust-/),
|
||||
)
|
||||
expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 })
|
||||
})
|
||||
|
||||
it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => {
|
||||
vi.mocked(adjustPublishedInboundFee).mockRejectedValue(
|
||||
new ApiResponseError('费用已被其他操作员修改,请刷新后重试', 'A0409', 409),
|
||||
)
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
|
||||
numbers[0]!.value = '1200'
|
||||
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[1]!.value = '80'
|
||||
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
|
||||
reason.value = '并发场景价格修正'
|
||||
reason.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
reason.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.includes('确认调整并生成新版本'))?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledTimes(2)
|
||||
expect(document.body.textContent).toContain('系统已刷新最新快照')
|
||||
expect(document.body.textContent).toContain('当前输入仍保留')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,477 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { CircleDollarSign, PencilLine, RefreshCw } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import {
|
||||
ApiResponseError,
|
||||
getApiErrorDetails,
|
||||
type ApiErrorDetails,
|
||||
} from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [snapshot: InboundFeeSnapshot]
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const adjusting = ref(false)
|
||||
const notFound = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const adjustmentError = ref<ApiErrorDetails | null>(null)
|
||||
const conflictRefreshed = ref(false)
|
||||
const snapshot = ref<InboundFeeSnapshot | null>(null)
|
||||
const adjustOpen = ref(false)
|
||||
const adjustFormRef = ref<FormInstance>()
|
||||
let loadSequence = 0
|
||||
let adjustmentFingerprint = ''
|
||||
let adjustmentIdempotencyKey = ''
|
||||
|
||||
const adjustmentForm = reactive({
|
||||
subtotalAmount: 0,
|
||||
additionalAmount: 0,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const adjustmentRules: FormRules = {
|
||||
subtotalAmount: [{ required: true, message: '请输入调整后的基础运费', trigger: 'change' }],
|
||||
additionalAmount: [{ required: true, message: '请输入调整后的附加费', trigger: 'change' }],
|
||||
reason: [
|
||||
{ required: true, whitespace: true, message: '请填写费用调整原因', trigger: 'blur' },
|
||||
{ min: 2, max: 500, message: '调整原因需为 2–500 个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const canView = computed(() => access.can('firstleg:fba-pricing:view'))
|
||||
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
|
||||
const customerContextId = computed(() => props.customerId || props.tenantId || '')
|
||||
const adjustmentEligible = computed(() => (
|
||||
snapshot.value?.feeStatus === 'SENT'
|
||||
&& snapshot.value.nextProcessStatus === 'INBOUNDED'
|
||||
))
|
||||
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
|
||||
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
|
||||
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
|
||||
|
||||
function feeStatusInfo(status?: string) {
|
||||
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
|
||||
CALCULATED: { label: '已试算', type: 'warning' },
|
||||
CONFIRMED: { label: '已确认', type: 'info' },
|
||||
SENT: { label: '已发布', type: 'success' },
|
||||
SUPERSEDED: { label: '历史版本', type: 'info' },
|
||||
}
|
||||
return map[status || ''] || { label: status || '未知状态', type: 'info' as const }
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits = 3) {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number(value))
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null | undefined, currency?: string) {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
|
||||
try {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency', currency: currency || 'CNY', minimumFractionDigits: 2,
|
||||
}).format(Number(value))
|
||||
} catch {
|
||||
return `${formatNumber(value, 2)} ${currency || ''}`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value.replace('T', ' ')
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function taxModeLabel(value: string) {
|
||||
return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value
|
||||
}
|
||||
|
||||
function roundingModeLabel(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下',
|
||||
HALF_UP: '四舍五入', HALF_DOWN: '五舍六入', HALF_EVEN: '银行家舍入',
|
||||
}
|
||||
return labels[value] || value
|
||||
}
|
||||
|
||||
async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
|
||||
const sequence = ++loadSequence
|
||||
loadError.value = null
|
||||
notFound.value = false
|
||||
if (!customerContextId.value || !props.inboundId || !canView.value) {
|
||||
snapshot.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getLatestInboundFee(customerContextId.value, props.inboundId)
|
||||
if (sequence !== loadSequence) return
|
||||
snapshot.value = result
|
||||
if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result)
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
snapshot.value = null
|
||||
if (error instanceof ApiResponseError && error.code === 'A0404') notFound.value = true
|
||||
else loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fillAdjustmentForm(value: InboundFeeSnapshot) {
|
||||
adjustmentForm.subtotalAmount = Number(value.subtotalAmount)
|
||||
adjustmentForm.additionalAmount = Number(value.additionalAmount)
|
||||
adjustmentForm.reason = ''
|
||||
adjustmentError.value = null
|
||||
conflictRefreshed.value = false
|
||||
adjustmentFingerprint = ''
|
||||
adjustmentIdempotencyKey = ''
|
||||
}
|
||||
|
||||
function openAdjustment() {
|
||||
if (!snapshot.value || !adjustmentEligible.value) return
|
||||
fillAdjustmentForm(snapshot.value)
|
||||
adjustOpen.value = true
|
||||
}
|
||||
|
||||
function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
|
||||
return {
|
||||
subtotalAmount: Number(adjustmentForm.subtotalAmount),
|
||||
additionalAmount: Number(adjustmentForm.additionalAmount),
|
||||
reason: adjustmentForm.reason.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function getAdjustmentKey(payload: AdjustPublishedInboundFeeRequest) {
|
||||
const fingerprint = JSON.stringify(payload)
|
||||
if (!adjustmentIdempotencyKey || fingerprint !== adjustmentFingerprint) {
|
||||
adjustmentFingerprint = fingerprint
|
||||
adjustmentIdempotencyKey = createFeeAdjustmentIdempotencyKey()
|
||||
}
|
||||
return adjustmentIdempotencyKey
|
||||
}
|
||||
|
||||
async function submitAdjustment() {
|
||||
if (!snapshot.value) return
|
||||
const valid = await adjustFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const payload = adjustmentPayload()
|
||||
adjustmentError.value = null
|
||||
conflictRefreshed.value = false
|
||||
adjusting.value = true
|
||||
try {
|
||||
const result = await adjustPublishedInboundFee(
|
||||
customerContextId.value,
|
||||
props.inboundId,
|
||||
payload,
|
||||
getAdjustmentKey(payload),
|
||||
)
|
||||
snapshot.value = result
|
||||
adjustOpen.value = false
|
||||
ElMessage.success(`费用已调整为第 ${result.feeVersion} 版`)
|
||||
emit('changed', result)
|
||||
} catch (error) {
|
||||
adjustmentError.value = getApiErrorDetails(error, '费用调整失败,请检查后重试')
|
||||
if (error instanceof ApiResponseError && error.code === 'A0409') {
|
||||
await loadFee({ preserveAdjustment: true })
|
||||
conflictRefreshed.value = true
|
||||
adjustmentFingerprint = ''
|
||||
adjustmentIdempotencyKey = ''
|
||||
}
|
||||
} finally {
|
||||
adjusting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerContextId.value, props.inboundId, access.permissionSignature],
|
||||
() => {
|
||||
adjustOpen.value = false
|
||||
snapshot.value = null
|
||||
void loadFee()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
defineExpose({ reload: loadFee })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-fee-panel" :aria-busy="loading" aria-labelledby="inbound-fee-title">
|
||||
<header class="fee-heading">
|
||||
<div class="fee-heading-copy">
|
||||
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
|
||||
<div>
|
||||
<h3 id="inbound-fee-title">入库费用快照</h3>
|
||||
<p>展示审核完成后锁定的计费依据和对客费用,不在此重复触发试算或发布。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fee-heading-actions">
|
||||
<el-tooltip content="刷新费用快照" placement="top">
|
||||
<el-button
|
||||
circle
|
||||
aria-label="刷新费用快照"
|
||||
:loading="loading"
|
||||
:disabled="!customerContextId || !inboundId || !canView"
|
||||
@click="loadFee()"
|
||||
><RefreshCw :size="16" /></el-button>
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
v-if="canAdjust && adjustmentEligible"
|
||||
data-testid="adjust-fee-button"
|
||||
type="primary"
|
||||
@click="openAdjustment"
|
||||
><PencilLine :size="16" />调整已发布费用</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||
<template #title>当前账号没有查看入库费用的权限</template>
|
||||
<p>需要权限:firstleg:fba-pricing:view</p>
|
||||
</el-alert>
|
||||
|
||||
<div v-else-if="loading && !snapshot" class="fee-loading" aria-live="polite">
|
||||
<el-skeleton :rows="6" animated />
|
||||
<span class="sr-only">正在加载费用快照</span>
|
||||
</div>
|
||||
|
||||
<el-alert v-else-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title>费用快照加载失败</template>
|
||||
<p>{{ loadError.message }}</p>
|
||||
<ApiErrorMeta :details="loadError" />
|
||||
<el-button size="small" @click="loadFee()">重新加载</el-button>
|
||||
</el-alert>
|
||||
|
||||
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
|
||||
<p class="empty-note">测量审核完成后,系统会自动生成并发布费用。</p>
|
||||
</el-empty>
|
||||
|
||||
<div v-else-if="snapshot" class="fee-content">
|
||||
<div class="fee-summary" aria-label="费用关键结果">
|
||||
<div class="fee-total">
|
||||
<span>对客费用总额 · {{ snapshot.currency }}</span>
|
||||
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
|
||||
<small>基础运费小计 {{ formatMoney(snapshot.subtotalAmount, snapshot.currency) }} · 附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</small>
|
||||
</div>
|
||||
<div class="fee-summary-item">
|
||||
<span>最终计费量</span>
|
||||
<strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong>
|
||||
<small>{{ chargeModeLabel }}</small>
|
||||
</div>
|
||||
<div class="fee-summary-item">
|
||||
<span>快照版本</span>
|
||||
<div class="version-status"><strong>V{{ snapshot.feeVersion }}</strong><el-tag :type="statusInfo.type" effect="light">{{ statusInfo.label }}</el-tag></div>
|
||||
<small>{{ snapshot.feeNo }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="calculation-section" aria-labelledby="calculation-title">
|
||||
<div class="section-title">
|
||||
<div><h4 id="calculation-title">计费过程</h4><p>实际量与折算量取大后计费,金额以服务端快照为准。</p></div>
|
||||
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
|
||||
</div>
|
||||
<div class="calculation-flow">
|
||||
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
|
||||
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
|
||||
<div><span>折算量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
|
||||
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
|
||||
</div>
|
||||
<div class="amount-equation" aria-label="费用计算公式">
|
||||
<span>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</span>
|
||||
<i>×</i>
|
||||
<span>{{ formatMoney(snapshot.unitPrice, snapshot.currency) }} / {{ chargeUnitLabel }}</span>
|
||||
<i>+</i>
|
||||
<span>附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</span>
|
||||
<i>=</i>
|
||||
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="fee-detail-grid">
|
||||
<section aria-labelledby="rule-title">
|
||||
<div class="section-title"><div><h4 id="rule-title">客户计费规则</h4><p>{{ snapshot.ruleCode }} · V{{ snapshot.chargeRuleVersion }}</p></div></div>
|
||||
<dl>
|
||||
<div><dt>客户</dt><dd>{{ snapshot.customerName }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ snapshot.businessType }}</dd></div>
|
||||
<div><dt>计费口径</dt><dd>{{ chargeModeLabel }}</dd></div>
|
||||
<div><dt>舍入规则</dt><dd>保留 {{ snapshot.roundingScale }} 位,{{ roundingModeLabel(snapshot.roundingMode) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section aria-labelledby="rate-title">
|
||||
<div class="section-title"><div><h4 id="rate-title">船司 FBA 价格</h4><p>{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}</p></div></div>
|
||||
<dl>
|
||||
<div><dt>起运仓</dt><dd>{{ snapshot.originWarehouseName }}</dd></div>
|
||||
<div><dt>渠道 / 船司</dt><dd>{{ snapshot.channelCode }} / {{ snapshot.carrierName }}</dd></div>
|
||||
<div><dt>FBA 目的仓</dt><dd>{{ snapshot.destinationCode }}</dd></div>
|
||||
<div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="fee-timeline" aria-labelledby="fee-timeline-title">
|
||||
<div class="section-title"><div><h4 id="fee-timeline-title">快照时间</h4><p>每个节点均由后端状态机记录。</p></div></div>
|
||||
<ol>
|
||||
<li><span>试算</span><strong>{{ formatDateTime(snapshot.calculatedAt) }}</strong></li>
|
||||
<li :class="{ pending: !snapshot.confirmedAt }"><span>确认</span><strong>{{ formatDateTime(snapshot.confirmedAt) }}</strong></li>
|
||||
<li :class="{ pending: !snapshot.sentAt }"><span>发布给客户</span><strong>{{ formatDateTime(snapshot.sentAt) }}</strong></li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AppDialog
|
||||
v-model="adjustOpen"
|
||||
title="调整已发布费用"
|
||||
width="min(560px, calc(100vw - 28px))"
|
||||
confirm-text="确认调整并生成新版本"
|
||||
confirm-type="warning"
|
||||
:confirm-loading="adjusting"
|
||||
@confirm="submitAdjustment"
|
||||
>
|
||||
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的已发布版本,不会回退实物入库状态。" />
|
||||
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
|
||||
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
|
||||
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
|
||||
</el-alert>
|
||||
<el-alert v-if="adjustmentError" class="adjustment-alert" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title>费用调整失败</template>
|
||||
<p>{{ adjustmentError.message }}</p>
|
||||
<ApiErrorMeta :details="adjustmentError" />
|
||||
</el-alert>
|
||||
<el-form ref="adjustFormRef" :model="adjustmentForm" :rules="adjustmentRules" label-position="top" class="adjustment-form" :disabled="adjusting">
|
||||
<div class="adjustment-amounts">
|
||||
<el-form-item label="调整后基础运费" prop="subtotalAmount" required>
|
||||
<el-input-number v-model="adjustmentForm.subtotalAmount" aria-label="调整后基础运费" :min="0" :max="999999999999.99" :precision="2" :step="100" controls-position="right" />
|
||||
<span>{{ snapshot?.currency }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整后附加费" prop="additionalAmount" required>
|
||||
<el-input-number v-model="adjustmentForm.additionalAmount" aria-label="调整后附加费" :min="0" :max="999999999999.99" :precision="2" :step="10" controls-position="right" />
|
||||
<span>{{ snapshot?.currency }}</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="adjustment-total"><span>调整后总额</span><strong>{{ formatMoney(adjustmentForm.subtotalAmount + adjustmentForm.additionalAmount, snapshot?.currency) }}</strong></div>
|
||||
<el-form-item label="调整原因" prop="reason" required>
|
||||
<el-input v-model="adjustmentForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="说明价格变更、补充费用或修正原因,记录将进入审计历史" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-fee-panel { min-width: 0; color: var(--cc-text); }
|
||||
.fee-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; }
|
||||
.fee-heading-copy { display: flex; min-width: 0; align-items: flex-start; gap: 12px; }
|
||||
.fee-heading-icon { display: grid; width: 38px; height: 38px; flex: 0 0 38px; place-items: center; border-radius: 10px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.fee-heading h3, .section-title h4 { margin: 0; }
|
||||
.fee-heading h3 { font-size: 16px; }
|
||||
.fee-heading p, .section-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.fee-heading-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.fee-heading-actions .el-button { margin-left: 0; }
|
||||
.fee-loading { padding: 20px 0; }
|
||||
.inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; }
|
||||
.inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; }
|
||||
.empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.fee-content { border-top: 1px solid var(--cc-border); }
|
||||
.fee-summary { display: grid; grid-template-columns: minmax(260px, 1.35fr) repeat(2, minmax(180px, 1fr)); border-bottom: 1px solid var(--cc-border); background: #f8fafc; }
|
||||
.fee-total, .fee-summary-item { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 6px; padding: 20px; }
|
||||
.fee-summary-item { border-left: 1px solid var(--cc-border); }
|
||||
.fee-summary span, .fee-summary small { color: var(--cc-muted); font-size: 11px; }
|
||||
.fee-summary strong { font-size: 18px; line-height: 1.25; overflow-wrap: anywhere; }
|
||||
.fee-total strong { color: #0f5c49; font-size: 27px; letter-spacing: -.02em; }
|
||||
.version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.calculation-section, .fee-timeline { padding: 22px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.section-title h4 { font-size: 14px; }
|
||||
.calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 16px; border: 1px solid var(--cc-border); border-radius: 12px; overflow: hidden; }
|
||||
.calculation-flow > div { display: flex; min-width: 0; min-height: 88px; flex-direction: column; justify-content: center; gap: 6px; padding: 14px 16px; border-right: 1px solid var(--cc-border); }
|
||||
.calculation-flow > div:last-child { border-right: 0; }
|
||||
.calculation-flow span, .calculation-flow small { color: var(--cc-muted); font-size: 11px; }
|
||||
.calculation-flow strong { font-size: 15px; overflow-wrap: anywhere; }
|
||||
.calculation-flow .is-chargeable { background: #f0fdf9; }
|
||||
.calculation-flow .is-chargeable strong { color: #087d70; }
|
||||
.amount-equation { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; padding: 12px 14px; border-radius: 8px; background: #f8fafc; color: #475467; font-size: 12px; }
|
||||
.amount-equation i { color: #98a2b3; font-style: normal; }
|
||||
.amount-equation strong { color: var(--cc-text); font-size: 15px; }
|
||||
.fee-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid var(--cc-border); }
|
||||
.fee-detail-grid > section { min-width: 0; padding: 22px 20px 22px 0; }
|
||||
.fee-detail-grid > section + section { padding-right: 0; padding-left: 20px; border-left: 1px solid var(--cc-border); }
|
||||
.fee-detail-grid dl { margin: 14px 0 0; }
|
||||
.fee-detail-grid dl > div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 9px 0; border-top: 1px solid #edf0f4; font-size: 12px; }
|
||||
.fee-detail-grid dt { color: var(--cc-muted); }
|
||||
.fee-detail-grid dd { min-width: 0; margin: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.fee-timeline { border-bottom: 0; }
|
||||
.fee-timeline ol { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin: 18px 0 0; padding: 0; list-style: none; }
|
||||
.fee-timeline li { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 0 18px 0 20px; }
|
||||
.fee-timeline li::before { position: absolute; top: 4px; left: 0; width: 9px; height: 9px; border-radius: 50%; background: #12b76a; content: ''; }
|
||||
.fee-timeline li::after { position: absolute; top: 8px; right: 0; left: 10px; z-index: -1; height: 1px; background: var(--cc-border); content: ''; }
|
||||
.fee-timeline li:last-child::after { display: none; }
|
||||
.fee-timeline li.pending::before { background: #98a2b3; }
|
||||
.fee-timeline span { color: var(--cc-muted); font-size: 11px; }
|
||||
.fee-timeline strong { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.adjustment-alert { margin-top: 12px; }
|
||||
.adjustment-form { margin-top: 18px; }
|
||||
.adjustment-amounts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.adjustment-amounts :deep(.el-form-item__content) { flex-wrap: nowrap; }
|
||||
.adjustment-amounts :deep(.el-input-number) { width: 100%; }
|
||||
.adjustment-amounts :deep(.el-form-item__content > span) { margin-left: 8px; color: var(--cc-muted); font-size: 12px; }
|
||||
.adjustment-total { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 18px; padding: 13px 14px; border-radius: 8px; background: #f8fafc; }
|
||||
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
|
||||
.adjustment-total strong { font-size: 18px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fee-summary { grid-template-columns: 1fr 1fr; }
|
||||
.fee-total { grid-column: 1 / -1; }
|
||||
.fee-summary-item:first-of-type { border-left: 0; }
|
||||
.calculation-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.calculation-flow > div:nth-child(2) { border-right: 0; }
|
||||
.calculation-flow > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fee-heading { align-items: stretch; flex-direction: column; }
|
||||
.fee-heading-actions { justify-content: flex-start; }
|
||||
.fee-heading-actions .el-button:not(.is-circle) { flex: 1; min-height: 44px; }
|
||||
.fee-summary, .calculation-flow, .fee-detail-grid, .fee-timeline ol, .adjustment-amounts { grid-template-columns: 1fr; }
|
||||
.fee-summary-item { border-top: 1px solid var(--cc-border); border-left: 0; }
|
||||
.calculation-flow > div { min-height: 76px; border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.calculation-flow > div:last-child { border-bottom: 0; }
|
||||
.fee-detail-grid > section, .fee-detail-grid > section + section { padding: 20px 0; border-left: 0; }
|
||||
.fee-detail-grid > section + section { border-top: 1px solid var(--cc-border); }
|
||||
.fee-timeline ol { gap: 16px; }
|
||||
.fee-timeline li { padding-left: 20px; }
|
||||
.fee-timeline li::after { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.fee-heading-copy { gap: 8px; }
|
||||
.fee-heading-icon { width: 34px; height: 34px; flex-basis: 34px; }
|
||||
.fee-total, .fee-summary-item { padding: 16px 12px; }
|
||||
.fee-total strong { font-size: 22px; }
|
||||
.fee-detail-grid dl > div { grid-template-columns: 1fr; gap: 4px; }
|
||||
.amount-equation { align-items: flex-start; flex-direction: column; }
|
||||
.amount-equation i { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
|
||||
return { ...actual, http: { get: vi.fn(), put: vi.fn() } }
|
||||
})
|
||||
|
||||
const snapshot = {
|
||||
feeId: '9007199254740993',
|
||||
feeNo: 'FBAF-88-2',
|
||||
feeVersion: 2,
|
||||
feeStatus: 'SENT',
|
||||
inboundId: '88',
|
||||
totalAmount: 1280,
|
||||
}
|
||||
|
||||
describe('inbound fee API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads the latest immutable fee snapshot with string IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot })
|
||||
|
||||
await expect(getLatestInboundFee('10001', '88')).resolves.toEqual(snapshot)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/latest', {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('adjusts a published snapshot with the required idempotency key', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } })
|
||||
const payload = { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费' }
|
||||
|
||||
await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1'))
|
||||
.resolves.toMatchObject({ feeVersion: 3 })
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/88/fees/published',
|
||||
payload,
|
||||
{ params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-adjust-1' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves backend conflict metadata for stale-version recovery', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({
|
||||
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
|
||||
requestId: 'request-fee-409', traceId: 'trace-fee-409',
|
||||
})
|
||||
|
||||
const error = await adjustPublishedInboundFee(
|
||||
'10001',
|
||||
'88',
|
||||
{ subtotalAmount: 1200, additionalAmount: 80, reason: '价格修正' },
|
||||
'fee-adjust-2',
|
||||
).catch((reason: unknown) => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiResponseError)
|
||||
expect(error).toMatchObject({ code: 'A0409', requestId: 'request-fee-409', traceId: 'trace-fee-409' })
|
||||
})
|
||||
|
||||
it('creates bounded unique keys for adjustment retries', () => {
|
||||
const first = createFeeAdjustmentIdempotencyKey()
|
||||
const second = createFeeAdjustmentIdempotencyKey()
|
||||
expect(first).toMatch(/^fee-adjust-/)
|
||||
expect(first.length).toBeLessThanOrEqual(128)
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
|
||||
function feeBasePath(inboundId: string) {
|
||||
return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees`
|
||||
}
|
||||
|
||||
export async function getLatestInboundFee(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/latest`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function adjustPublishedInboundFee(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
payload: AdjustPublishedInboundFeeRequest,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await http.put<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/published`,
|
||||
payload,
|
||||
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function createFeeAdjustmentIdempotencyKey() {
|
||||
const uuid = globalThis.crypto?.randomUUID?.()
|
||||
if (uuid) return `fee-adjust-${uuid}`
|
||||
return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export { default as InboundFeePanel } from './InboundFeePanel.vue'
|
||||
export {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
export type {
|
||||
AdjustPublishedInboundFeeRequest,
|
||||
InboundChargeMode,
|
||||
InboundFeeSnapshot,
|
||||
InboundFeeStatus,
|
||||
} from './types'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export type InboundFeeStatus = 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'SUPERSEDED' | string
|
||||
export type InboundChargeMode = 'KG' | 'CBM' | string
|
||||
|
||||
export interface InboundFeeSnapshot {
|
||||
feeId: string
|
||||
feeNo: string
|
||||
feeVersion: number
|
||||
feeStatus: InboundFeeStatus
|
||||
inboundId: string
|
||||
inboundCode: string | null
|
||||
chargeRuleId: string
|
||||
ruleCode: string
|
||||
chargeRuleVersion: number
|
||||
carrierRateId: string
|
||||
rateCode: string
|
||||
carrierRateVersion: number
|
||||
customerId: string
|
||||
customerName: string
|
||||
originWarehouseId: string
|
||||
originWarehouseName: string
|
||||
businessType: string
|
||||
channelCode: string
|
||||
carrierId: string
|
||||
carrierName: string
|
||||
destinationCode: string
|
||||
taxMode: string
|
||||
chargeMode: InboundChargeMode
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
convertedQuantity: number
|
||||
chargeableQuantity: number
|
||||
chargeUnit: string
|
||||
unitPrice: number | null
|
||||
subtotalAmount: number
|
||||
additionalAmount: number
|
||||
totalAmount: number
|
||||
currency: string
|
||||
rateEffectiveFrom: string
|
||||
rateEffectiveTo: string | null
|
||||
calculatedAt: string
|
||||
confirmedAt: string | null
|
||||
sentAt: string | null
|
||||
nextProcessStatus: string | null
|
||||
}
|
||||
|
||||
export interface AdjustPublishedInboundFeeRequest {
|
||||
subtotalAmount: number
|
||||
additionalAmount: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import {
|
||||
getInboundMeasurement,
|
||||
saveInboundMeasurementDraft,
|
||||
submitInboundMeasurement,
|
||||
} from './api'
|
||||
import type { InboundMeasurementDetail } from './types'
|
||||
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
getInboundMeasurement: vi.fn(),
|
||||
saveInboundMeasurementDraft: vi.fn(),
|
||||
submitInboundMeasurement: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
function measurement(overrides: Partial<InboundMeasurementDetail> = {}): InboundMeasurementDetail {
|
||||
return {
|
||||
inboundId: '7001',
|
||||
inboundCode: 'RK202608040001',
|
||||
companyName: '测试企业',
|
||||
channelCode: 'SEA-FBA',
|
||||
channelName: '美西海运',
|
||||
processStatus: 'MEASURING',
|
||||
measureReviewStatus: null,
|
||||
recommendedPalletType: 'STANDARD',
|
||||
reviewReason: null,
|
||||
reviewerId: null,
|
||||
reviewerName: null,
|
||||
measurementId: '9001',
|
||||
sourceType: 'THIRD_PARTY',
|
||||
measurementStatus: 'RECEIVED',
|
||||
version: 2,
|
||||
originalDocumentNo: 'DOC-001',
|
||||
originalGrossWeightKg: 118.5,
|
||||
originalVolumeCbm: 0.8,
|
||||
actualPackageCount: 10,
|
||||
actualGrossWeightKg: 120,
|
||||
actualVolumeCbm: 0.6,
|
||||
volumeWeightKg: 100.2,
|
||||
actualGoodsName: '蓝牙音箱',
|
||||
items: [{
|
||||
id: '9101',
|
||||
itemNo: '001',
|
||||
boxSpecification: '50×40×30cm',
|
||||
packageCount: 10,
|
||||
lengthCm: 50,
|
||||
widthCm: 40,
|
||||
heightCm: 30,
|
||||
girthCm: 190,
|
||||
unitWeightKg: 12,
|
||||
unitVolumeCbm: 0.06,
|
||||
totalWeightKg: 120,
|
||||
totalVolumeCbm: 0.6,
|
||||
volumeWeightKg: 100.2,
|
||||
intelligentImageFileId: '88001',
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: '设备自动回传',
|
||||
}],
|
||||
weightResults: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountPanel(permissions = [
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'platform:firstleg:inbound:measure:edit',
|
||||
'platform:firstleg:inbound:measure:submit',
|
||||
]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(InboundMeasurementPanel, {
|
||||
props: { customerId: '10001', inboundId: '7001' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('InboundMeasurementPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(getInboundMeasurement).mockReset().mockResolvedValue(measurement())
|
||||
vi.mocked(saveInboundMeasurementDraft).mockReset().mockResolvedValue({ measurementId: '9001', version: 3, status: 'DRAFT' })
|
||||
vi.mocked(submitInboundMeasurement).mockReset().mockResolvedValue({
|
||||
measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING',
|
||||
recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null,
|
||||
})
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
})
|
||||
|
||||
it('loads third-party facts and renders server-calculated values separately', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001')
|
||||
expect(wrapper.text()).toContain('第三方设备')
|
||||
expect(wrapper.text()).toContain('RK202608040001')
|
||||
expect(wrapper.text()).toContain('箱规周长')
|
||||
expect(wrapper.text()).toContain('190 cm')
|
||||
expect(wrapper.text()).toContain('0.6 CBM')
|
||||
expect((wrapper.find('input[placeholder="请输入本箱规的补充说明"]').element as HTMLInputElement).value).toBe('设备自动回传')
|
||||
})
|
||||
|
||||
it('follows the prototype grouping, labels, and editable field order', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const text = wrapper.text()
|
||||
|
||||
expect(text).toContain('原单数据')
|
||||
expect(text).toContain('入库单测量数据明细')
|
||||
expect(text).toContain('实测总箱数')
|
||||
expect(text).toContain('实测总毛重')
|
||||
expect(text).toContain('实测总体积')
|
||||
expect(text).toContain('实测总材积重')
|
||||
expect(text).not.toContain('租户')
|
||||
|
||||
const labels = wrapper.findAll('.measurement-item__grid label > span').map((label) => label.text())
|
||||
expect(labels.slice(0, 8)).toEqual([
|
||||
'明细编号 *',
|
||||
'箱规 *',
|
||||
'箱规长(cm)*',
|
||||
'箱规宽(cm)*',
|
||||
'箱规高(cm)*',
|
||||
'箱数 *',
|
||||
'每箱毛重(kg)*',
|
||||
'智能图片文件 ID',
|
||||
])
|
||||
})
|
||||
|
||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(wrapper.classes()).toContain('surface-panel')
|
||||
expect(wrapper.find('.measurement-panel__heading').classes()).toContain('panel-heading')
|
||||
expect(wrapper.find('.status-badge').text()).toBe('测量中')
|
||||
})
|
||||
|
||||
it('saves only editable raw fields with the current measurement version', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('智能音箱')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledOnce()
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
|
||||
measurementId: '9001',
|
||||
version: 2,
|
||||
actualGoodsName: '智能音箱',
|
||||
items: [expect.objectContaining({
|
||||
itemNo: '001', packageCount: 10, lengthCm: 50, intelligentImageFileId: '88001',
|
||||
})],
|
||||
}))
|
||||
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0][2]
|
||||
expect(payload.items[0]).not.toHaveProperty('totalVolumeCbm')
|
||||
})
|
||||
|
||||
it('preserves the edited form when the backend reports a version conflict', async () => {
|
||||
vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce(
|
||||
new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 200, null, 'req-conflict', 'trace-conflict'),
|
||||
)
|
||||
const wrapper = await mountPanel()
|
||||
const goodsInput = wrapper.find('input[placeholder="请输入实际货物名称"]')
|
||||
await goodsInput.setValue('保留我的修改')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('数据版本已变化,当前修改尚未丢失')
|
||||
expect(wrapper.text()).toContain('req-conflict')
|
||||
expect((goodsInput.element as HTMLInputElement).value).toBe('保留我的修改')
|
||||
expect(getInboundMeasurement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requires the dirty draft to be saved before submitting for review', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('尚未保存')
|
||||
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
|
||||
await submit?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(submitInboundMeasurement).not.toHaveBeenCalled()
|
||||
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits the saved version and emits changed after confirmation', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
|
||||
await submit?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('准备托盘与库位占用'),
|
||||
'提交测量审核',
|
||||
expect.objectContaining({ confirmButtonText: '确认提交' }),
|
||||
)
|
||||
expect(submitInboundMeasurement).toHaveBeenCalledWith('10001', '7001', { measurementId: '9001', version: 2 })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears the previous customer context before loading the next one', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ inboundId: '7002', inboundCode: 'RK202608040002', companyName: '第二企业' }))
|
||||
|
||||
await wrapper.setProps({ customerId: '10002', inboundId: '7002' })
|
||||
await flushPromises()
|
||||
|
||||
expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002')
|
||||
expect(wrapper.text()).toContain('RK202608040002')
|
||||
expect(wrapper.text()).not.toContain('测试企业')
|
||||
})
|
||||
|
||||
it('renders a permission-specific read-only state without requesting data', async () => {
|
||||
const wrapper = await mountPanel([])
|
||||
|
||||
expect(wrapper.text()).toContain('无测量查看权限')
|
||||
expect(getInboundMeasurement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,635 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { AlertTriangle, Plus, RefreshCw, Ruler, Save, Send, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api'
|
||||
import type {
|
||||
InboundMeasurementDetail,
|
||||
InboundMeasurementItem,
|
||||
SaveInboundMeasurementDraftRequest,
|
||||
SaveInboundMeasurementItemRequest,
|
||||
} from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
|
||||
clientKey: string
|
||||
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
|
||||
}
|
||||
|
||||
interface MeasurementFormModel {
|
||||
measurementId: string | null
|
||||
version: number
|
||||
originalDocumentNo: string
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualGoodsName: string
|
||||
items: EditableMeasurementItem[]
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detail = ref<InboundMeasurementDetail | null>(null)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const operationError = ref<ApiErrorDetails | null>(null)
|
||||
const conflictDetected = ref(false)
|
||||
const validationErrors = ref<Record<string, string[]>>({})
|
||||
const baseline = ref('')
|
||||
let requestSequence = 0
|
||||
let itemSequence = 0
|
||||
|
||||
const form = reactive<MeasurementFormModel>({
|
||||
measurementId: null,
|
||||
version: 0,
|
||||
originalDocumentNo: '',
|
||||
originalGrossWeightKg: null,
|
||||
originalVolumeCbm: null,
|
||||
actualGoodsName: '',
|
||||
items: [],
|
||||
})
|
||||
|
||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
|
||||
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
|
||||
const editableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
||||
const mayMaintain = computed(() => canEdit.value && editableProcess.value && !loading.value && !submitting.value)
|
||||
const maySubmit = computed(() => canSubmit.value && editableProcess.value && Boolean(form.measurementId) && !loading.value && !saving.value)
|
||||
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))
|
||||
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
|
||||
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || detail.value?.sourceType || '尚未测量'))
|
||||
const sourceTagType = computed(() => detail.value?.sourceType === 'MANUAL' ? 'info' : 'primary')
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'p2-inbound-measurement-editor',
|
||||
label: computed(() => `入库单 ${detail.value?.inboundCode || props.inboundId} 的测量草稿`),
|
||||
isOpen: registrationOpen,
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
|
||||
function optionalText(value: string) {
|
||||
const normalized = value.trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function nextItemKey() {
|
||||
itemSequence += 1
|
||||
return `measurement-item-${itemSequence}`
|
||||
}
|
||||
|
||||
function emptyItem(): EditableMeasurementItem {
|
||||
return {
|
||||
clientKey: nextItemKey(),
|
||||
itemNo: '',
|
||||
boxSpecification: '',
|
||||
packageCount: 1,
|
||||
lengthCm: 0,
|
||||
widthCm: 0,
|
||||
heightCm: 0,
|
||||
unitWeightKg: 0,
|
||||
intelligentImageFileId: null,
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: null,
|
||||
serverValues: { girthCm: null, unitVolumeCbm: null, totalWeightKg: null, totalVolumeCbm: null, volumeWeightKg: null },
|
||||
}
|
||||
}
|
||||
|
||||
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
||||
return {
|
||||
clientKey: nextItemKey(),
|
||||
itemNo: item.itemNo,
|
||||
boxSpecification: item.boxSpecification,
|
||||
packageCount: item.packageCount,
|
||||
lengthCm: item.lengthCm,
|
||||
widthCm: item.widthCm,
|
||||
heightCm: item.heightCm,
|
||||
unitWeightKg: item.unitWeightKg,
|
||||
intelligentImageFileId: item.intelligentImageFileId == null ? null : String(item.intelligentImageFileId),
|
||||
overLimitWarning: item.overLimitWarning,
|
||||
outerLengthCm: item.outerLengthCm,
|
||||
outerWidthCm: item.outerWidthCm,
|
||||
outerHeightCm: item.outerHeightCm,
|
||||
remark: item.remark,
|
||||
serverValues: {
|
||||
girthCm: item.girthCm,
|
||||
unitVolumeCbm: item.unitVolumeCbm,
|
||||
totalWeightKg: item.totalWeightKg,
|
||||
totalVolumeCbm: item.totalVolumeCbm,
|
||||
volumeWeightKg: item.volumeWeightKg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
form.measurementId = null
|
||||
form.version = 0
|
||||
form.originalDocumentNo = ''
|
||||
form.originalGrossWeightKg = null
|
||||
form.originalVolumeCbm = null
|
||||
form.actualGoodsName = ''
|
||||
form.items.splice(0)
|
||||
baseline.value = JSON.stringify(form)
|
||||
captureBaseline()
|
||||
}
|
||||
|
||||
function applyDetail(resource: InboundMeasurementDetail) {
|
||||
detail.value = resource
|
||||
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
|
||||
form.version = resource.version ?? 0
|
||||
form.originalDocumentNo = resource.originalDocumentNo || ''
|
||||
form.originalGrossWeightKg = resource.originalGrossWeightKg
|
||||
form.originalVolumeCbm = resource.originalVolumeCbm
|
||||
form.actualGoodsName = resource.actualGoodsName || ''
|
||||
form.items.splice(0, form.items.length, ...resource.items.map(editableItem))
|
||||
baseline.value = JSON.stringify(form)
|
||||
captureBaseline()
|
||||
validationErrors.value = {}
|
||||
conflictDetected.value = false
|
||||
}
|
||||
|
||||
async function loadMeasurement() {
|
||||
if (!hasContext.value || !canView.value) return
|
||||
const sequence = ++requestSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
operationError.value = null
|
||||
try {
|
||||
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
|
||||
if (sequence !== requestSequence) return
|
||||
applyDetail(resource)
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence) return
|
||||
detail.value = null
|
||||
clearForm()
|
||||
loadError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadLatest() {
|
||||
if (isDirty.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('加载最新版本会丢弃当前尚未保存的修改。确定继续吗?', '重新加载测量数据', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '加载最新版本',
|
||||
cancelButtonText: '继续编辑',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
await loadMeasurement()
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (form.items.length >= 500) {
|
||||
ElMessage.warning('单个测量批次最多包含 500 条箱规')
|
||||
return
|
||||
}
|
||||
form.items.push(emptyItem())
|
||||
}
|
||||
|
||||
async function removeItem(index: number) {
|
||||
if (saving.value || submitting.value) return
|
||||
if (form.items.length === 1) {
|
||||
ElMessage.warning('测量草稿至少需要一条箱规')
|
||||
return
|
||||
}
|
||||
form.items.splice(index, 1)
|
||||
}
|
||||
|
||||
function isPositive(value: number | null) {
|
||||
return value != null && Number.isFinite(value) && value > 0
|
||||
}
|
||||
|
||||
function isNonNegative(value: number | null) {
|
||||
return value == null || (Number.isFinite(value) && value >= 0)
|
||||
}
|
||||
|
||||
function validateDraft() {
|
||||
const errors: Record<string, string[]> = {}
|
||||
const general: string[] = []
|
||||
if (!form.items.length) general.push('请至少添加一条箱规')
|
||||
if (form.items.length > 500) general.push('单个测量批次最多包含 500 条箱规')
|
||||
if (form.originalDocumentNo.trim().length > 64) general.push('原始单据号不能超过 64 个字符')
|
||||
if (form.actualGoodsName.trim().length > 255) general.push('实际品名不能超过 255 个字符')
|
||||
if (!isNonNegative(form.originalGrossWeightKg)) general.push('申报毛重不能小于 0')
|
||||
if (!isNonNegative(form.originalVolumeCbm)) general.push('申报体积不能小于 0')
|
||||
form.items.forEach((item, index) => {
|
||||
const itemErrors: string[] = []
|
||||
if (!item.itemNo.trim()) itemErrors.push('请输入箱规序号')
|
||||
else if (item.itemNo.trim().length > 32) itemErrors.push('箱规序号不能超过 32 个字符')
|
||||
if (!item.boxSpecification.trim()) itemErrors.push('请输入箱规描述')
|
||||
else if (item.boxSpecification.trim().length > 64) itemErrors.push('箱规描述不能超过 64 个字符')
|
||||
if (!Number.isInteger(item.packageCount) || item.packageCount < 1 || item.packageCount > 1_000_000) itemErrors.push('箱数必须是 1 至 1,000,000 的整数')
|
||||
if (!isPositive(item.lengthCm) || !isPositive(item.widthCm) || !isPositive(item.heightCm)) itemErrors.push('长、宽、高必须大于 0')
|
||||
if (!isPositive(item.unitWeightKg)) itemErrors.push('单箱重量必须大于 0')
|
||||
if (![item.outerLengthCm, item.outerWidthCm, item.outerHeightCm].every(isNonNegative)) itemErrors.push('外包装尺寸不能小于 0')
|
||||
if (item.intelligentImageFileId && !/^\d+$/.test(item.intelligentImageFileId)) itemErrors.push('测量图片文件 ID 必须是正整数')
|
||||
if ((item.overLimitWarning || '').length > 500 || (item.remark || '').length > 500) itemErrors.push('提示或备注不能超过 500 个字符')
|
||||
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
|
||||
})
|
||||
if (general.length) errors.general = general
|
||||
validationErrors.value = errors
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
function buildPayload(): SaveInboundMeasurementDraftRequest {
|
||||
return {
|
||||
measurementId: form.measurementId,
|
||||
version: form.version,
|
||||
originalDocumentNo: optionalText(form.originalDocumentNo),
|
||||
originalGrossWeightKg: form.originalGrossWeightKg,
|
||||
originalVolumeCbm: form.originalVolumeCbm,
|
||||
actualGoodsName: optionalText(form.actualGoodsName),
|
||||
items: form.items.map((item) => ({
|
||||
itemNo: item.itemNo.trim(),
|
||||
boxSpecification: item.boxSpecification.trim(),
|
||||
packageCount: item.packageCount,
|
||||
lengthCm: item.lengthCm,
|
||||
widthCm: item.widthCm,
|
||||
heightCm: item.heightCm,
|
||||
unitWeightKg: item.unitWeightKg,
|
||||
intelligentImageFileId: item.intelligentImageFileId || null,
|
||||
overLimitWarning: optionalText(item.overLimitWarning || ''),
|
||||
outerLengthCm: item.outerLengthCm,
|
||||
outerWidthCm: item.outerWidthCm,
|
||||
outerHeightCm: item.outerHeightCm,
|
||||
remark: optionalText(item.remark || ''),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function setOperationError(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
operationError.value = details
|
||||
conflictDetected.value = details.code === 'A0409'
|
||||
|| details.code === 'CONFLICT'
|
||||
|| details.code === 'HTTP_409'
|
||||
|| details.message.includes('版本')
|
||||
|| details.message.includes('并发')
|
||||
return details
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!mayMaintain.value || !validateDraft()) return
|
||||
saving.value = true
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
try {
|
||||
const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, buildPayload())
|
||||
form.measurementId = String(saved.measurementId)
|
||||
form.version = saved.version
|
||||
ElMessage.success('测量草稿已保存,计算结果已由服务端更新')
|
||||
await loadMeasurement()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '测量草稿保存失败,请检查后重试')
|
||||
if (!conflictDetected.value) ElMessage.error(details.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForReview() {
|
||||
if (!maySubmit.value || !form.measurementId) return
|
||||
if (isDirty.value) {
|
||||
ElMessage.warning('请先保存当前修改,再提交审核')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'提交后将校验测量图片和全部称重结果,并由后端准备托盘与库位占用。提交成功后本批次不能继续编辑。',
|
||||
'提交测量审核',
|
||||
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
try {
|
||||
await submitInboundMeasurement(customerId.value, props.inboundId, {
|
||||
measurementId: form.measurementId,
|
||||
version: form.version,
|
||||
})
|
||||
ElMessage.success('测量数据已提交审核')
|
||||
await loadMeasurement()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
|
||||
if (!conflictDetected.value) ElMessage.error(details.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
|
||||
if (value == null || !Number.isFinite(value)) return '—'
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
|
||||
}
|
||||
|
||||
function processStatusLabel(status: string | undefined) {
|
||||
return ({
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '审核驳回',
|
||||
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用确认中', INBOUNDING: '入库中', INBOUNDED: '已入库', VOIDED: '已作废',
|
||||
} as Record<string, string>)[status || ''] || status || '—'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerId.value, props.inboundId, canView.value] as const,
|
||||
() => {
|
||||
requestSequence += 1
|
||||
detail.value = null
|
||||
loadError.value = null
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
validationErrors.value = {}
|
||||
clearForm()
|
||||
if (hasContext.value && canView.value) void loadMeasurement()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface-panel measurement-panel" aria-label="入库测量草稿">
|
||||
<header class="panel-heading measurement-panel__heading">
|
||||
<div>
|
||||
<span class="measurement-panel__icon"><Ruler :size="18" aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>测量与箱规</h2>
|
||||
<p>维护箱规原始值;体积、材积重和汇总结果均由服务端计算。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="measurement-panel__actions">
|
||||
<el-button :loading="loading" :disabled="!hasContext || !canView || saving || submitting" @click="reloadLatest">
|
||||
<RefreshCw :size="15" />刷新
|
||||
</el-button>
|
||||
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft">
|
||||
<Save :size="15" />保存草稿
|
||||
</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview">
|
||||
<Send :size="15" />提交审核
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-empty v-if="!hasContext" description="请选择所属企业和入库单后维护测量数据" />
|
||||
|
||||
<LoadErrorState
|
||||
v-else-if="!canView"
|
||||
title="无测量查看权限"
|
||||
message="当前账号缺少 platform:firstleg:inbound:measure:view 权限。"
|
||||
:show-retry="false"
|
||||
/>
|
||||
|
||||
<div v-else-if="loading && !detail" class="measurement-skeleton" aria-label="正在加载测量数据">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<LoadErrorState
|
||||
v-else-if="loadError && !detail"
|
||||
title="测量数据加载失败"
|
||||
:message="loadError.message"
|
||||
:loading="loading"
|
||||
@retry="loadMeasurement"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="measurement-context">
|
||||
<div class="measurement-context__identity">
|
||||
<div>
|
||||
<strong>{{ detail.inboundCode }}</strong>
|
||||
<span>{{ detail.companyName }} · {{ detail.channelName || detail.channelCode || '未设置渠道' }}</span>
|
||||
</div>
|
||||
<div class="measurement-tags">
|
||||
<el-tag :type="sourceTagType" effect="light">{{ sourceLabel }}</el-tag>
|
||||
<StatusBadge :status="processStatusLabel(detail.processStatus)" />
|
||||
<span>版本 {{ form.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="measurement-metrics">
|
||||
<div><dt>实测总箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, 0) }}</dd></div>
|
||||
<div><dt>实测总毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>T.K.GW</small></dd></div>
|
||||
<div><dt>实测总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>T.CBM</small></dd></div>
|
||||
<div><dt>实测总材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>KG</small></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="!editableProcess" type="info" :closable="false" show-icon>
|
||||
<template #title>当前状态为“{{ processStatusLabel(detail.processStatus) }}”,测量数据仅可查看</template>
|
||||
测量草稿只允许在待测量、测量中或审核驳回状态维护。
|
||||
</el-alert>
|
||||
|
||||
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
|
||||
|
||||
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>数据版本已变化,当前修改尚未丢失</template>
|
||||
<p>{{ operationError?.message }}</p>
|
||||
<ApiErrorMeta v-if="operationError" :details="operationError" />
|
||||
<el-button size="small" @click="reloadLatest">加载最新版本</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-else-if="operationError" type="error" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>操作未完成</template>
|
||||
<p>{{ operationError.message }}</p>
|
||||
<ApiErrorMeta :details="operationError" />
|
||||
</el-alert>
|
||||
|
||||
<el-alert v-if="Object.keys(validationErrors).length" type="error" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>请修正测量草稿中的问题</template>
|
||||
<ul>
|
||||
<li v-for="message in Object.values(validationErrors).flat()" :key="message">{{ message }}</li>
|
||||
</ul>
|
||||
</el-alert>
|
||||
|
||||
<form class="measurement-form" @submit.prevent="saveDraft">
|
||||
<fieldset :disabled="!mayMaintain || saving">
|
||||
<legend class="sr-only">测量草稿</legend>
|
||||
<section class="measurement-section">
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>原单数据</h3><p>原单值仅用于审核对比,不参与前端计算。</p></div>
|
||||
</div>
|
||||
<div class="measurement-form-grid">
|
||||
<label><span>原单据号码(CKW)</span><el-input v-model="form.originalDocumentNo" maxlength="64" placeholder="请输入原单据号码" /></label>
|
||||
<label><span>原单据毛重(T.K.GW)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" :controls="false" placeholder="0.000" /></label>
|
||||
<label><span>原单据总体积(T.CBM)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" :controls="false" placeholder="0.0000" /></label>
|
||||
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" maxlength="255" placeholder="请输入实际货物名称" /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="measurement-section measurement-items-section">
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>入库单测量数据明细</h3><p>最多 500 条;保存后由服务端重新计算箱规周长、单箱体积、总毛重、总体积和总材积重。</p></div>
|
||||
<el-button v-if="canEdit" :disabled="!mayMaintain || form.items.length >= 500" @click="addItem"><Plus :size="15" />新增箱规</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!form.items.length" description="暂无箱规明细">
|
||||
<el-button v-if="mayMaintain" type="primary" @click="addItem"><Plus :size="15" />添加第一条箱规</el-button>
|
||||
</el-empty>
|
||||
|
||||
<article v-for="(item, index) in form.items" :key="item.clientKey" class="measurement-item" :class="{ 'has-error': validationErrors[item.clientKey]?.length }">
|
||||
<header>
|
||||
<div><strong>箱规 {{ index + 1 }}</strong><span>{{ item.itemNo || '待填写序号' }} · {{ item.boxSpecification || '待填写箱规' }}</span></div>
|
||||
<el-button v-if="canEdit" link type="danger" :disabled="!mayMaintain || form.items.length === 1" @click="removeItem(index)"><Trash2 :size="15" />删除</el-button>
|
||||
</header>
|
||||
<div class="measurement-item__grid">
|
||||
<label><span>明细编号 *</span><el-input v-model="item.itemNo" maxlength="32" placeholder="例如 001" /></label>
|
||||
<label><span>箱规 *</span><el-input v-model="item.boxSpecification" maxlength="64" placeholder="例如 50×40×30cm" /></label>
|
||||
<label><span>箱规长(cm)*</span><el-input-number v-model="item.lengthCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱规宽(cm)*</span><el-input-number v-model="item.widthCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱规高(cm)*</span><el-input-number v-model="item.heightCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱数 *</span><el-input-number v-model="item.packageCount" :min="1" :max="1000000" :step="1" step-strictly controls-position="right" /></label>
|
||||
<label><span>每箱毛重(kg)*</span><el-input-number v-model="item.unitWeightKg" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>智能图片文件 ID</span><el-input v-model="item.intelligentImageFileId" inputmode="numeric" placeholder="文件服务返回的 ID" /></label>
|
||||
</div>
|
||||
<details class="measurement-item__advanced">
|
||||
<summary>外包装尺寸与补充说明</summary>
|
||||
<div class="measurement-item__grid">
|
||||
<label><span>外箱规长(cm)</span><el-input-number v-model="item.outerLengthCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label><span>外箱规宽(cm)</span><el-input-number v-model="item.outerWidthCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label><span>外箱规高(cm)</span><el-input-number v-model="item.outerHeightCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label class="span-two"><span>超限提醒</span><el-input v-model="item.overLimitWarning" maxlength="500" placeholder="设备或人工发现的尺寸异常" /></label>
|
||||
<label class="span-two"><span>备注</span><el-input v-model="item.remark" maxlength="500" placeholder="请输入本箱规的补充说明" /></label>
|
||||
</div>
|
||||
</details>
|
||||
<dl class="measurement-item__results" aria-label="服务端计算结果">
|
||||
<div><dt>箱规周长</dt><dd>{{ formatNumber(item.serverValues.girthCm) }} cm</dd></div>
|
||||
<div><dt>单箱体积</dt><dd>{{ formatNumber(item.serverValues.unitVolumeCbm, 4) }} CBM</dd></div>
|
||||
<div><dt>总毛重</dt><dd>{{ formatNumber(item.serverValues.totalWeightKg) }} KG</dd></div>
|
||||
<div><dt>总体积</dt><dd>{{ formatNumber(item.serverValues.totalVolumeCbm, 4) }} CBM</dd></div>
|
||||
<div><dt>总材积重</dt><dd>{{ formatNumber(item.serverValues.volumeWeightKg) }} KG</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
|
||||
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
|
||||
<span v-else>当前显示版本 {{ form.version }}</span>
|
||||
<div>
|
||||
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存草稿</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.measurement-panel { overflow: hidden; }
|
||||
.measurement-panel__heading > div:first-child { display: flex; min-width: 0; align-items: flex-start; gap: 11px; }
|
||||
.measurement-panel__icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.measurement-panel h2, .measurement-panel h3 { margin: 0; color: var(--cc-text); }
|
||||
.measurement-panel h2 { font-size: 16px; }
|
||||
.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-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
|
||||
.measurement-skeleton { padding: 24px; }
|
||||
.measurement-context { padding: 18px 20px; background: #fbfcfe; }
|
||||
.measurement-context__identity { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.measurement-context__identity > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.measurement-context__identity strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; }
|
||||
.measurement-context__identity span { color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.measurement-tags { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 7px; }
|
||||
.measurement-tags > span:last-child { color: var(--cc-muted); font-size: 11px; }
|
||||
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 16px 0 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.measurement-metrics > div { min-width: 0; padding: 13px 15px; border-right: 1px solid var(--cc-border); }
|
||||
.measurement-metrics > div:last-child { border-right: 0; }
|
||||
.measurement-metrics dt { color: var(--cc-muted); font-size: 11px; }
|
||||
.measurement-metrics dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; font-weight: 700; }
|
||||
.measurement-metrics small { color: var(--cc-muted); font-size: 10px; font-weight: 500; }
|
||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
|
||||
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
|
||||
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
|
||||
.measurement-alert .el-button { margin-top: 9px; }
|
||||
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.measurement-section { padding: 22px 20px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.measurement-section__heading > div { min-width: 0; }
|
||||
.measurement-form-grid, .measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px; }
|
||||
.measurement-form label, .measurement-item label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
|
||||
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
|
||||
.measurement-items-section { background: #fafbfc; }
|
||||
.measurement-item { overflow: hidden; margin-top: 14px; border: 1px solid var(--cc-border); border-radius: 9px; background: #fff; }
|
||||
.measurement-item.has-error { border-color: var(--el-color-danger-light-5); }
|
||||
.measurement-item > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 15px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; }
|
||||
.measurement-item > header > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.measurement-item > header strong { color: var(--cc-text); font-size: 13px; }
|
||||
.measurement-item > header span { overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.measurement-item__grid { padding: 16px; }
|
||||
.measurement-item__advanced { margin: 0 16px 16px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-item__advanced summary { padding: 12px 0 0; color: var(--cc-primary); font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.measurement-item__advanced .measurement-item__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 14px 0 0; }
|
||||
.measurement-item__advanced .span-two { grid-column: span 3; }
|
||||
.measurement-item__results { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); background: #fbfcfe; }
|
||||
.measurement-item__results > div { min-width: 0; padding: 11px 14px; border-right: 1px solid var(--cc-border); }
|
||||
.measurement-item__results > div:last-child { border-right: 0; }
|
||||
.measurement-item__results dt { color: var(--cc-muted); font-size: 10px; }
|
||||
.measurement-item__results dd { margin: 4px 0 0; overflow-wrap: anywhere; color: #344054; font-size: 12px; font-weight: 700; }
|
||||
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 20px; border-top: 1px solid var(--cc-border); background: rgba(255, 255, 255, .97); box-shadow: 0 -8px 22px rgba(15, 23, 42, .05); }
|
||||
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
|
||||
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.measurement-form-grid, .measurement-item__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-metrics > div:nth-child(2) { border-right: 0; }
|
||||
.measurement-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-item__results { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.measurement-item__results > div:nth-child(3) { border-right: 0; }
|
||||
.measurement-item__results > div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.measurement-panel__heading, .measurement-context__identity, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
|
||||
.measurement-panel__actions, .measurement-panel__actions :deep(.el-button), .measurement-panel__footer > div, .measurement-panel__footer :deep(.el-button) { width: 100%; }
|
||||
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button), .measurement-section__heading :deep(.el-button) { min-height: 44px; }
|
||||
.measurement-tags { justify-content: flex-start; }
|
||||
.measurement-context, .measurement-section { padding-right: 14px; padding-left: 14px; }
|
||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
|
||||
.measurement-form-grid, .measurement-item__grid, .measurement-item__advanced .measurement-item__grid { grid-template-columns: 1fr; }
|
||||
.measurement-item__advanced .span-two { grid-column: auto; }
|
||||
.measurement-item__results { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-item__results > div { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-item__results > div:nth-child(even) { border-right: 0; }
|
||||
.measurement-item__results > div:last-child { border-bottom: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.measurement-metrics, .measurement-item__results { grid-template-columns: 1fr; }
|
||||
.measurement-metrics > div, .measurement-item__results > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-metrics > div:last-child, .measurement-item__results > div:last-child { border-bottom: 0; }
|
||||
.measurement-item > header { align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api'
|
||||
import type { SaveInboundMeasurementDraftRequest } from './types'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), put: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const draft: SaveInboundMeasurementDraftRequest = {
|
||||
measurementId: '9001',
|
||||
version: 2,
|
||||
originalDocumentNo: 'DOC-01',
|
||||
originalGrossWeightKg: 120,
|
||||
originalVolumeCbm: 1.2,
|
||||
actualGoodsName: '蓝牙音箱',
|
||||
items: [{
|
||||
itemNo: '001',
|
||||
boxSpecification: '50×40×30cm',
|
||||
packageCount: 10,
|
||||
lengthCm: 50,
|
||||
widthCm: 40,
|
||||
heightCm: 30,
|
||||
unitWeightKg: 12,
|
||||
intelligentImageFileId: '88001',
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: null,
|
||||
}],
|
||||
}
|
||||
|
||||
describe('inbound measurement API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
})
|
||||
|
||||
it('maps the selected customer to the backend tenant compatibility parameter', async () => {
|
||||
const detail = { inboundId: '7001', items: [], weightResults: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(getInboundMeasurement('10001', '7001')).resolves.toEqual(detail)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/inbounds/7001/measurement', {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('saves a versioned draft without inventing an idempotency header', async () => {
|
||||
const saved = { measurementId: '9001', version: 3, status: 'DRAFT' }
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: saved })
|
||||
|
||||
await expect(saveInboundMeasurementDraft('10001', '7001', draft)).resolves.toEqual(saved)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/7001/measurement/draft',
|
||||
draft,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('submits the server measurement id and optimistic-lock version', async () => {
|
||||
const submitted = { measurementId: '9001', version: 3, processStatus: 'MEASURE_PENDING' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: submitted })
|
||||
|
||||
await expect(submitInboundMeasurement('10001', '7001', { measurementId: '9001', version: 3 })).resolves.toEqual(submitted)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/7001/measurement/submit',
|
||||
{ measurementId: '9001', version: 3 },
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
InboundMeasurementDetail,
|
||||
InboundMeasurementDraftSaved,
|
||||
InboundMeasurementSubmitted,
|
||||
SaveInboundMeasurementDraftRequest,
|
||||
SubmitInboundMeasurementRequest,
|
||||
} from './types'
|
||||
|
||||
function measurementPath(inboundId: string) {
|
||||
return `/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement`
|
||||
}
|
||||
|
||||
export async function getInboundMeasurement(customerId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<InboundMeasurementDetail>, ApiEnvelope<InboundMeasurementDetail>>(
|
||||
measurementPath(inboundId),
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function saveInboundMeasurementDraft(
|
||||
customerId: string,
|
||||
inboundId: string,
|
||||
payload: SaveInboundMeasurementDraftRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<InboundMeasurementDraftSaved>, ApiEnvelope<InboundMeasurementDraftSaved>>(
|
||||
`${measurementPath(inboundId)}/draft`,
|
||||
payload,
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function submitInboundMeasurement(
|
||||
customerId: string,
|
||||
inboundId: string,
|
||||
payload: SubmitInboundMeasurementRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<InboundMeasurementSubmitted>, ApiEnvelope<InboundMeasurementSubmitted>>(
|
||||
`${measurementPath(inboundId)}/submit`,
|
||||
payload,
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as InboundMeasurementPanel } from './InboundMeasurementPanel.vue'
|
||||
export * from './api'
|
||||
export type * from './types'
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
export type MeasurementIdentifier = string
|
||||
|
||||
export type InboundMeasurementProcessStatus =
|
||||
| 'WAIT_MEASURE'
|
||||
| 'MEASURING'
|
||||
| 'MEASURE_PENDING'
|
||||
| 'MEASURE_REJECTED'
|
||||
| 'WAIT_PRICING'
|
||||
| 'FEE_CONFIRMING'
|
||||
| 'INBOUNDING'
|
||||
| 'INBOUNDED'
|
||||
| 'VOIDED'
|
||||
|
||||
export interface InboundMeasurementItem {
|
||||
id: MeasurementIdentifier
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
lengthCm: number
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
girthCm: number | null
|
||||
unitWeightKg: number
|
||||
unitVolumeCbm: number | null
|
||||
totalWeightKg: number | null
|
||||
totalVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface InboundMeasurementWeightResult {
|
||||
id: MeasurementIdentifier
|
||||
fileId: MeasurementIdentifier
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
recognitionStatus: string
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: MeasurementIdentifier | null
|
||||
adjustedAt: string | null
|
||||
confirmedBy: MeasurementIdentifier | null
|
||||
confirmedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface InboundMeasurementDetail {
|
||||
inboundId: MeasurementIdentifier
|
||||
inboundCode: string
|
||||
companyName: string
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
processStatus: InboundMeasurementProcessStatus
|
||||
measureReviewStatus: string | null
|
||||
recommendedPalletType: string | null
|
||||
reviewReason: string | null
|
||||
reviewerId: MeasurementIdentifier | null
|
||||
reviewerName: string | null
|
||||
measurementId: MeasurementIdentifier | null
|
||||
sourceType: string | null
|
||||
measurementStatus: string | null
|
||||
version: number | null
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
items: InboundMeasurementItem[]
|
||||
weightResults: InboundMeasurementWeightResult[]
|
||||
}
|
||||
|
||||
export interface SaveInboundMeasurementItemRequest {
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
lengthCm: number
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
unitWeightKg: number
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface SaveInboundMeasurementDraftRequest {
|
||||
measurementId: MeasurementIdentifier | null
|
||||
version: number
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualGoodsName: string | null
|
||||
items: SaveInboundMeasurementItemRequest[]
|
||||
}
|
||||
|
||||
export interface InboundMeasurementDraftSaved {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface SubmitInboundMeasurementRequest {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface InboundMeasurementSubmitted {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
processStatus: string
|
||||
measureReviewStatus: string
|
||||
recommendedPalletType: string | null
|
||||
feeId: MeasurementIdentifier | null
|
||||
feeNo: string | null
|
||||
feeStatus: string | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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/pages/InboundOrderDetailView.vue'), 'utf8')
|
||||
|
||||
describe('InboundOrderDetailView', () => {
|
||||
it('maps the compatible route parameter to customer context without a detail-page selector', () => {
|
||||
expect(source).toContain('route.query.tenantId')
|
||||
expect(source).toContain('const customerId = computed')
|
||||
expect(source).toContain("name: 'inbound-orders'")
|
||||
expect(source).toContain(':customer-id="customerId"')
|
||||
expect(source).toContain(':inbound-id="inboundId"')
|
||||
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
|
||||
expect(source).not.toContain('PlatformTenantSelector')
|
||||
expect(source).not.toContain('租户')
|
||||
expect(source).toContain('<h1>入库详情</h1>')
|
||||
expect(source).toContain('货物测量、审核、费用及托盘库位记录')
|
||||
expect(source).toContain('class="surface-panel detail-page"')
|
||||
expect(source).toContain('class="detail-page-actions"')
|
||||
expect(source).toContain('返回入库单列表')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import InboundWorkflowWorkspace from '../components/InboundWorkflowWorkspace.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function routeValue(value: unknown) {
|
||||
if (Array.isArray(value)) return String(value[0] || '')
|
||||
return value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
const inboundId = computed(() => routeValue(route.params.inboundId))
|
||||
const customerId = computed(() => routeValue(route.query.tenantId))
|
||||
|
||||
function backToList() {
|
||||
void router.push({ name: 'inbound-orders', query: customerId.value ? { tenantId: customerId.value } : undefined })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page inbound-detail-page">
|
||||
<section class="surface-panel detail-page">
|
||||
<header class="detail-page-header">
|
||||
<div class="detail-page-heading">
|
||||
<h1>入库详情</h1>
|
||||
<p>查看基本信息、货物测量、审核、费用及托盘库位记录。</p>
|
||||
</div>
|
||||
<div class="detail-page-actions">
|
||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="detail-page-content">
|
||||
<InboundWorkflowWorkspace
|
||||
v-if="inboundId && customerId"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
/>
|
||||
<el-empty
|
||||
v-else
|
||||
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-detail-page { min-width: 0; }
|
||||
.detail-page { overflow: hidden; }
|
||||
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-page-heading { min-width: 0; }
|
||||
.detail-page-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
|
||||
.detail-page-heading p { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
|
||||
.detail-page-content { min-width: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
||||
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import InboundOrdersView from './InboundOrdersView.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import type { InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
|
||||
vi.mock('../core/api', () => ({
|
||||
getPlatformInbound: vi.fn(),
|
||||
listPlatformInbounds: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({
|
||||
listPlatformWarehouses: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/iam', () => ({
|
||||
listPlatformTenants: vi.fn(),
|
||||
}))
|
||||
|
||||
const customers: PlatformTenantResource[] = [
|
||||
{ id: '10001', tenantCode: 'CUS-001', tenantName: '示例企业', status: 'ACTIVE', legalName: '示例企业有限公司', legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
|
||||
{ id: '10002', tenantCode: 'CUS-002', tenantName: '第二客户', status: 'ACTIVE', legalName: null, legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
|
||||
]
|
||||
|
||||
const summary: InboundOrderSummary = {
|
||||
id: '19001', orderId: '18001', orderNo: 'ORD-001', inboundCode: 'RK-001', processStatus: 'WAIT_MEASURE',
|
||||
orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null,
|
||||
notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户',
|
||||
companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null,
|
||||
deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
|
||||
customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8',
|
||||
holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110,
|
||||
actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01',
|
||||
measureCompletedAt: null, inboundedAt: null, expressCompanyCode: null, expressCompanyName: null, expressTrackingNo: null,
|
||||
loadingStatus: null, containerNo: null, cabinetNo: null, vesselName: null, voyageNo: null, sailingAt: null, portArrivalAt: null,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00',
|
||||
}
|
||||
|
||||
const detail: InboundOrderDetail = {
|
||||
summary,
|
||||
publicRemark: '请优先安排测量',
|
||||
palletLocations: [{ allocationNo: 1, warehouseId: '20001', palletId: '21001', palletCode: 'P-001', palletCapacityCbm: 2, allocatedVolumeCbm: 1.2, allocatedPackageCount: 6, locationId: '22001', locationCode: 'A-01', reservationNo: 'RES-1', allocationStatus: 'PREPARED' }],
|
||||
files: [], internalNotes: [], statusHistory: [{ fromStatus: null, toStatus: 'WAIT_MEASURE', action: 'CREATE', reason: null, operatorId: '2', operatorName: '管理员', occurredAt: '2026-08-01T10:00:00' }],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountPage() {
|
||||
return mount(InboundOrdersView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
async function selectCustomer(wrapper: ReturnType<typeof mountPage>, customerId = '10001') {
|
||||
await flushPromises()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId })
|
||||
await flushPromises()
|
||||
return toolbar
|
||||
}
|
||||
|
||||
describe('InboundOrdersView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformInbound).mockResolvedValue(detail)
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 })
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({
|
||||
records: [
|
||||
{ id: '20001', tenantId: '10001', warehouseCode: 'YIWU', warehouseName: '义乌收货仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
|
||||
{ id: 20002, tenantId: '10001', warehouseCode: 'B001', warehouseName: '交货仓 B001', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
|
||||
],
|
||||
page: 1, size: 200, total: 2, pages: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the customer-scoped list and renders the first-column view action', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const customerField = (toolbar.props('fields') as Array<{ key: string; placeholder?: string }>).find((field) => field.key === 'customerId')
|
||||
expect(customerField?.placeholder).toBe('全部客户')
|
||||
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
|
||||
expect(listPlatformInbounds).toHaveBeenCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
const requestsBeforeSearch = vi.mocked(listPlatformInbounds).mock.calls.length
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeSearch + 1)
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
|
||||
|
||||
await selectCustomer(wrapper)
|
||||
expect(listPlatformInbounds).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith('10001', 1, 200)
|
||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(document.body.textContent).toContain('RK-001')
|
||||
expect(document.body.textContent).toContain('查看')
|
||||
expect(document.body.textContent).toContain('所属企业 示例企业有限公司')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部')
|
||||
})
|
||||
|
||||
it('uses backend-supported warehouse IDs from tenant-scoped options without coercing Long IDs', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
toolbar.vm.$emit('update:modelValue', {
|
||||
...toolbar.props('modelValue'),
|
||||
warehouseId: '20001',
|
||||
deliveryWarehouseId: '20002',
|
||||
})
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||
warehouseId: '20001',
|
||||
deliveryWarehouseId: '20002',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps common filters compact and clears hidden advanced filters when collapsed', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
const fieldKeys = () => (toolbar.props('fields') as Array<{ key: string }>).map((field) => field.key)
|
||||
const widths = Object.fromEntries((toolbar.props('fields') as Array<{ key: string; width?: number }>).map((field) => [field.key, field.width]))
|
||||
expect(widths.customerId).toBe(260)
|
||||
expect(widths.processStatus).toBe(160)
|
||||
expect(widths.inboundCode).toBe(220)
|
||||
expect(widths.expectedArrivalRange).toBe(320)
|
||||
expect(fieldKeys()).toContain('expectedArrivalRange')
|
||||
expect(fieldKeys()).not.toContain('feeReviewStatus')
|
||||
|
||||
const advancedButton = () => wrapper.findAll('button').find((button) => button.text().includes('更多筛选') || button.text().includes('收起筛选'))
|
||||
await advancedButton()?.trigger('click')
|
||||
expect(fieldKeys()).toContain('feeReviewStatus')
|
||||
|
||||
toolbar.vm.$emit('update:modelValue', {
|
||||
...toolbar.props('modelValue'),
|
||||
feeReviewStatus: 'PENDING',
|
||||
destinationCode: 'ONT8',
|
||||
})
|
||||
await flushPromises()
|
||||
await advancedButton()?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(fieldKeys()).not.toContain('feeReviewStatus')
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||
feeReviewStatus: undefined,
|
||||
destinationCode: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('passes the selected backend tab and opens an aggregate read-only detail', async () => {
|
||||
const wrapper = mountPage()
|
||||
await selectCustomer(wrapper)
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('.inbound-tab')].find((button) => button.textContent?.includes('待审核'))?.click()
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 }))
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
|
||||
await flushPromises()
|
||||
expect(getPlatformInbound).toHaveBeenCalledWith('10001', '19001')
|
||||
expect(document.body.textContent).toContain('托盘与库位')
|
||||
expect(document.body.textContent).toContain('请优先安排测量')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses the app header as the page title and exposes compact accessible status tabs', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.inbound-page-heading').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('所有处理均进入单票工作台完成')
|
||||
const tabList = wrapper.find('[role="tablist"]')
|
||||
const tabButtons = tabList.findAll('[role="tab"]')
|
||||
const queryToolbar = wrapper.findComponent(QueryToolbar)
|
||||
expect(tabList.attributes('aria-label')).toBe('入库单处理状态')
|
||||
expect(tabList.element.parentElement?.classList.contains('table-toolbar')).toBe(true)
|
||||
expect(queryToolbar.element.compareDocumentPosition(tabList.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(tabButtons).toHaveLength(5)
|
||||
expect(wrapper.find('.table-toolbar').text()).toContain('共 1 条 · 所属企业 全部客户')
|
||||
expect(wrapper.find('.table-toolbar').text()).toContain('刷新')
|
||||
expect(wrapper.text()).not.toContain('全部入库单')
|
||||
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
|
||||
expect(tabButtons[0].attributes('tabindex')).toBe('0')
|
||||
await tabButtons[0].trigger('keydown', { key: 'ArrowRight' })
|
||||
await flushPromises()
|
||||
expect(tabButtons[1].attributes('aria-selected')).toBe('true')
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ tab: 'WAIT_MEASURE', page: 1 }))
|
||||
})
|
||||
|
||||
it('shows an actionable list error and recovers on retry', async () => {
|
||||
vi.mocked(listPlatformInbounds).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('入库单暂时无法加载')
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '重新加载')?.click()
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenCalledTimes(2)
|
||||
expect(document.body.textContent).toContain('RK-001')
|
||||
})
|
||||
|
||||
it('clears the selected list and detail when the selected customer changes', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' })
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 }))
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith('10002', 1, 200)
|
||||
})
|
||||
|
||||
it('resets to all customers without sending an empty backend tenant ID', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
const requestsBeforeReset = vi.mocked(listPlatformInbounds).mock.calls.length
|
||||
const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置'))
|
||||
await resetButton?.trigger('click')
|
||||
await flushPromises()
|
||||
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
|
||||
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeReset + 1)
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1 }))
|
||||
expect(document.body.textContent).not.toContain('请选择所属企业')
|
||||
expect(document.body.textContent).not.toContain('请选择客户')
|
||||
})
|
||||
|
||||
it('loads another full-data page without a customer filter', async () => {
|
||||
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 60, pages: 3 })
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 2, size: 20 }))
|
||||
})
|
||||
|
||||
it('keeps the operation column first and follows the prototype field order with backend-backed columns', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrdersView.vue'), 'utf8')
|
||||
const toolbarSource = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
|
||||
const columnSource = source.slice(source.indexOf('const columns:'))
|
||||
const operation = columnSource.indexOf("key: 'actions'")
|
||||
const inboundCode = columnSource.indexOf("key: 'inboundCode'")
|
||||
const orderStatus = columnSource.indexOf("key: 'orderStatus'")
|
||||
const measureReview = columnSource.indexOf("key: 'measureReviewStatus'")
|
||||
const feeStatus = columnSource.indexOf("key: 'feeStatus'")
|
||||
const customer = columnSource.indexOf("key: 'customerName'")
|
||||
const warehouse = columnSource.indexOf("key: 'warehouseName'")
|
||||
expect(operation).toBeGreaterThan(-1)
|
||||
expect(operation).toBeLessThan(inboundCode)
|
||||
expect(inboundCode).toBeLessThan(orderStatus)
|
||||
expect(orderStatus).toBeLessThan(measureReview)
|
||||
expect(measureReview).toBeLessThan(feeStatus)
|
||||
expect(feeStatus).toBeLessThan(customer)
|
||||
expect(customer).toBeLessThan(warehouse)
|
||||
expect(columnSource).toContain("fixed: 'left'")
|
||||
expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, RefreshCw, SlidersHorizontal } from '@lucide/vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
|
||||
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
|
||||
import type { InboundListTab, InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
customerId: string
|
||||
inboundCode: string
|
||||
orderNo: string
|
||||
warehouseId: string
|
||||
deliveryWarehouseId: string
|
||||
processStatus: string
|
||||
orderStatus: string
|
||||
measureReviewStatus: string
|
||||
customerName: string
|
||||
destinationCode: string
|
||||
feeStatus: string
|
||||
feeReviewStatus: string
|
||||
holdFlag: string
|
||||
expectedArrivalRange: string[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const tab = ref<InboundListTab | ''>('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InboundOrderDetail | null>(null)
|
||||
const detailTarget = ref<InboundOrderSummary | null>(null)
|
||||
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
||||
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
|
||||
const selectedCustomerId = ref('')
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const warehousesLoading = ref(false)
|
||||
const warehousesUnavailable = ref(false)
|
||||
const showAdvancedFilters = ref(false)
|
||||
let loadRequestId = 0
|
||||
let detailRequestId = 0
|
||||
let customerRequestId = 0
|
||||
let warehouseRequestId = 0
|
||||
let disposed = false
|
||||
|
||||
const tabs: Array<{ value: InboundListTab | ''; label: string; description: string }> = [
|
||||
{ value: '', label: '全部', description: '全部入库单' },
|
||||
{ value: 'WAIT_MEASURE', label: '待测量', description: '等待测量或补正' },
|
||||
{ value: 'WAIT_REVIEW', label: '待审核', description: '等待测量审核' },
|
||||
{ value: 'WAIT_FEE_SEND', label: '待费用/入库', description: '等待费用编排完成' },
|
||||
{ value: 'INBOUNDED', label: '已入库', description: '费用已发布并完成入库' },
|
||||
]
|
||||
|
||||
const advancedFilterKeys = new Set(['orderStatus', 'feeReviewStatus', 'customerName', 'destinationCode', 'holdFlag'])
|
||||
|
||||
const allQueryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 260,
|
||||
placeholder: customerLoadError.value ? '所属企业加载失败' : customersLoading.value ? '正在加载客户' : '全部客户',
|
||||
options: customers.value.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
|
||||
})),
|
||||
props: { 'aria-label': '所属企业', loading: customersLoading.value, filterable: true, disabled: Boolean(customerLoadError.value) || customers.value.length === 0 },
|
||||
},
|
||||
{ key: 'warehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
||||
{ key: 'deliveryWarehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
||||
{ key: 'inboundCode', type: 'input', width: 220, placeholder: '入仓码' },
|
||||
{ key: 'orderNo', type: 'input', width: 220, placeholder: '头程订单号' },
|
||||
{ key: 'processStatus', type: 'select', width: 160, placeholder: '流程状态', options: [
|
||||
{ label: '待测量', value: 'WAIT_MEASURE' }, { label: '测量中', value: 'MEASURING' },
|
||||
{ label: '待测量审核', value: 'MEASURE_PENDING' }, { label: '测量已驳回', value: 'MEASURE_REJECTED' },
|
||||
{ label: '待计费', value: 'WAIT_PRICING' }, { label: '费用处理中', value: 'FEE_CONFIRMING' },
|
||||
{ label: '入库处理中', value: 'INBOUNDING' }, { label: '已入库', value: 'INBOUNDED' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'orderStatus', type: 'input', width: 200, placeholder: '订单状态码' },
|
||||
{ key: 'measureReviewStatus', type: 'select', width: 160, placeholder: '测量审核状态', options: [
|
||||
{ label: '待审核', value: 'PENDING' }, { label: '审核通过', value: 'APPROVED' }, { label: '审核驳回', value: 'REJECTED' },
|
||||
] },
|
||||
{ key: 'feeStatus', type: 'select', width: 160, placeholder: '费用状态', options: [
|
||||
{ label: '未计费', value: 'UNPRICED' }, { label: '已试算', value: 'CALCULATED' },
|
||||
{ label: '已确认', value: 'CONFIRMED' }, { label: '已发送', value: 'SENT' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'feeReviewStatus', type: 'input', width: 200, placeholder: '费用审核状态码' },
|
||||
{ key: 'customerName', type: 'input', width: 260, placeholder: '客户名称' },
|
||||
{ key: 'destinationCode', type: 'input', width: 220, placeholder: '目的仓编码' },
|
||||
{ key: 'holdFlag', type: 'select', width: 160, placeholder: '挂起状态', options: [{ label: '已挂起', value: 'true' }, { label: '未挂起', value: 'false' }] },
|
||||
{ key: 'expectedArrivalRange', type: 'daterange', width: 320, placeholder: '预计到达日期' },
|
||||
])
|
||||
|
||||
const queryFields = computed(() => showAdvancedFilters.value
|
||||
? allQueryFields.value
|
||||
: allQueryFields.value.filter((field) => !advancedFilterKeys.has(field.key)))
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 152, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', label: '入仓码', minWidth: 166, slotName: 'inbound' },
|
||||
{ key: 'orderStatus', label: '订单 / 流程状态', minWidth: 156, slotName: 'orderStatus' },
|
||||
{ key: 'measureReviewStatus', label: '测量数据审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
|
||||
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
|
||||
{ key: 'feeStatus', label: '费用状态', minWidth: 124, slotName: 'feeStatus' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' },
|
||||
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
|
||||
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
|
||||
{ key: 'loadingStatus', label: '配载状态', minWidth: 110, slotName: 'loadingStatus' },
|
||||
{ key: 'holdFlag', label: '是否扣货', width: 96, slotName: 'holdFlag' },
|
||||
{ key: 'currentHoldReason', label: '扣货理由', minWidth: 150, slotName: 'currentHoldReason' },
|
||||
{ key: 'containerNo', label: '集装箱号', minWidth: 132, slotName: 'containerNo' },
|
||||
{ key: 'cabinetNo', label: '柜号', minWidth: 118, slotName: 'cabinetNo' },
|
||||
{ key: 'companyName', label: '公司', minWidth: 160, slotName: 'companyName' },
|
||||
{ key: 'businessType', label: '业务类型', minWidth: 112, slotName: 'businessType' },
|
||||
{ key: 'channelName', label: '渠道', minWidth: 138, slotName: 'channelName' },
|
||||
{ key: 'taxMode', label: '计税模式', minWidth: 108, slotName: 'taxMode' },
|
||||
{ key: 'containerMode', label: '装载模式', minWidth: 108, slotName: 'containerMode' },
|
||||
{ key: 'customsInspection', label: '商检验', width: 92, slotName: 'customsInspection' },
|
||||
{ key: 'sailingAt', label: '开船时间', minWidth: 164, slotName: 'sailingAt' },
|
||||
{ key: 'portArrivalAt', label: '到港时间', minWidth: 164, slotName: 'portArrivalAt' },
|
||||
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' },
|
||||
{ key: 'actualGrossWeightKg', label: '总毛重(kg)', minWidth: 118, slotName: 'actualGrossWeightKg' },
|
||||
{ key: 'chargeableWeightKg', label: '计费重量(kg)', minWidth: 126, slotName: 'chargeableWeightKg' },
|
||||
{ key: 'actualVolumeCbm', label: '总体积(CBM)', minWidth: 122, slotName: 'actualVolumeCbm' },
|
||||
{ key: 'chargeableVolumeCbm', label: '计费体积(CBM)', minWidth: 132, slotName: 'chargeableVolumeCbm' },
|
||||
{ key: 'palletCount', label: '总托盘数', width: 100, slotName: 'palletCount' },
|
||||
{ key: 'locationCodes', label: '库位位置', minWidth: 124, slotName: 'locationCodes' },
|
||||
{ key: 'volumeWeightKg', label: '总材积重(kg)', minWidth: 126, slotName: 'volumeWeightKg' },
|
||||
{ key: 'actualNetWeightKg', label: '总净重(kg)', minWidth: 118, slotName: 'actualNetWeightKg' },
|
||||
{ key: 'destinationType', label: '目的仓类型', minWidth: 112, slotName: 'destinationType' },
|
||||
{ key: 'destinationName', label: '目的仓', minWidth: 132, slotName: 'destinationName' },
|
||||
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
|
||||
{ key: 'inboundedAt', label: '入库完成时间', minWidth: 164, slotName: 'inboundedAt' },
|
||||
{ key: 'expressCompanyName', label: '快递公司', minWidth: 120, slotName: 'expressCompanyName' },
|
||||
{ key: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'expressTrackingNo' },
|
||||
{ key: 'createdAt', label: '创建日期', minWidth: 164, slotName: 'createdAt' },
|
||||
]
|
||||
|
||||
const selectedCustomerName = computed(() => {
|
||||
const customer = customers.value.find((item) => String(item.id) === selectedCustomerId.value)
|
||||
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '全部客户'
|
||||
})
|
||||
const listEmptyText = '暂无符合条件的入库单'
|
||||
|
||||
function queryValue(value: string) { return value.trim() || undefined }
|
||||
function handleTabKeydown(event: KeyboardEvent, currentIndex: number) {
|
||||
const lastIndex = tabs.length - 1
|
||||
let nextIndex = currentIndex
|
||||
if (event.key === 'ArrowRight') nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
|
||||
else if (event.key === 'ArrowLeft') nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
|
||||
else if (event.key === 'Home') nextIndex = 0
|
||||
else if (event.key === 'End') nextIndex = lastIndex
|
||||
else return
|
||||
event.preventDefault()
|
||||
tab.value = tabs[nextIndex].value
|
||||
const tabButtons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
tabButtons?.[nextIndex]?.focus()
|
||||
}
|
||||
function dateParam(value: string | undefined, end = false) {
|
||||
if (!value) return undefined
|
||||
return `${value}T${end ? '23:59:59' : '00:00:00'}`
|
||||
}
|
||||
|
||||
async function loadWarehouseOptions() {
|
||||
const requestId = ++warehouseRequestId
|
||||
warehouseOptions.value = []
|
||||
warehousesUnavailable.value = false
|
||||
if (!selectedCustomerId.value) {
|
||||
warehousesLoading.value = false
|
||||
return
|
||||
}
|
||||
warehousesLoading.value = true
|
||||
try {
|
||||
const response = await listPlatformWarehouses(selectedCustomerId.value, 1, 200)
|
||||
if (disposed || requestId !== warehouseRequestId) return
|
||||
warehouseOptions.value = response.records.map((warehouse) => ({
|
||||
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
|
||||
value: String(warehouse.id),
|
||||
}))
|
||||
} catch {
|
||||
if (disposed || requestId !== warehouseRequestId) return
|
||||
warehousesUnavailable.value = true
|
||||
} finally {
|
||||
if (!disposed && requestId === warehouseRequestId) warehousesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
const requestId = ++customerRequestId
|
||||
customersLoading.value = true
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const response = await listPlatformTenants({ page: currentPage, size: 200 })
|
||||
records.push(...response.records)
|
||||
pages = Math.max(1, response.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
if (disposed || requestId !== customerRequestId) return
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== customerRequestId) return
|
||||
customers.value = []
|
||||
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按所属企业筛选;入库单仍按全部客户查询')
|
||||
} finally {
|
||||
if (!disposed && requestId === customerRequestId) customersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInbounds() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
const range = filters.value.expectedArrivalRange
|
||||
try {
|
||||
const response = await listPlatformInbounds(selectedCustomerId.value || undefined, {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
tab: tab.value || undefined,
|
||||
inboundCode: queryValue(filters.value.inboundCode),
|
||||
orderNo: queryValue(filters.value.orderNo),
|
||||
warehouseId: queryValue(filters.value.warehouseId),
|
||||
deliveryWarehouseId: queryValue(filters.value.deliveryWarehouseId),
|
||||
processStatus: queryValue(filters.value.processStatus),
|
||||
orderStatus: queryValue(filters.value.orderStatus),
|
||||
measureReviewStatus: queryValue(filters.value.measureReviewStatus),
|
||||
customerName: queryValue(filters.value.customerName),
|
||||
destinationCode: queryValue(filters.value.destinationCode),
|
||||
feeStatus: queryValue(filters.value.feeStatus),
|
||||
feeReviewStatus: queryValue(filters.value.feeReviewStatus),
|
||||
holdFlag: filters.value.holdFlag === '' ? undefined : filters.value.holdFlag === 'true',
|
||||
expectedArrivalFrom: dateParam(range?.[0]),
|
||||
expectedArrivalTo: dateParam(range?.[1], true),
|
||||
})
|
||||
if (disposed || requestId !== loadRequestId) return
|
||||
result.value = { records: response.records, total: response.total }
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== loadRequestId) return
|
||||
loadError.value = getApiErrorDetails(error, '入库单加载失败')
|
||||
} finally {
|
||||
if (!disposed && requestId === loadRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
if (page.value === 1) void loadInbounds()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function resetDetail() {
|
||||
detailRequestId += 1
|
||||
detail.value = null
|
||||
detailTarget.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
async function openDetail(row: InboundOrderSummary) {
|
||||
const requestId = ++detailRequestId
|
||||
detailOpen.value = true
|
||||
detailTarget.value = row
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
detail.value = null
|
||||
try {
|
||||
const response = await getPlatformInbound(recordCustomerId(row), row.id)
|
||||
if (disposed || requestId !== detailRequestId) return
|
||||
detail.value = response
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== detailRequestId) return
|
||||
detailError.value = getApiErrorDetails(error, '入库单详情加载失败')
|
||||
} finally {
|
||||
if (!disposed && requestId === detailRequestId) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() { resetDetail() }
|
||||
function recordCustomerId(row: InboundOrderSummary) { return row.customerId || row.companyId || selectedCustomerId.value }
|
||||
function openWorkspace(row: InboundOrderSummary) {
|
||||
const customerId = recordCustomerId(row)
|
||||
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: customerId ? { tenantId: customerId } : undefined })
|
||||
}
|
||||
function formatNumber(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function text(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function statusLabel(status: string) {
|
||||
return ({ WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回', WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废' } as Record<string, string>)[status] || status || '未知状态'
|
||||
}
|
||||
function statusType(status: string) {
|
||||
if (status === 'INBOUNDED') return 'success'
|
||||
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
|
||||
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
function codeLabel(status: string | null | undefined, labels: Record<string, string>) { return status ? labels[status] || status : '—' }
|
||||
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
|
||||
const feeStatusLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
|
||||
const loadingStatusLabels: Record<string, string> = { NOT_LOADED: '未配载', LOADING: '配载中', LOADED: '已配载' }
|
||||
const taxModeLabels: Record<string, string> = { TAX_INCLUDED: '包税', TAX_EXCLUDED: '不包税', DDP: '包税', DDU: '不包税' }
|
||||
const containerModeLabels: Record<string, string> = { FCL: '整柜', LCL: '拼箱' }
|
||||
|
||||
function resetFilters() {
|
||||
selectedCustomerId.value = ''
|
||||
showAdvancedFilters.value = false
|
||||
filters.value = { ...filters.value, customerId: '', warehouseId: '', deliveryWarehouseId: '' }
|
||||
warehouseOptions.value = []
|
||||
warehousesUnavailable.value = false
|
||||
resetDetail()
|
||||
detailOpen.value = false
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function toggleAdvancedFilters() {
|
||||
if (!showAdvancedFilters.value) {
|
||||
showAdvancedFilters.value = true
|
||||
return
|
||||
}
|
||||
showAdvancedFilters.value = false
|
||||
filters.value = {
|
||||
...filters.value,
|
||||
orderStatus: '',
|
||||
feeReviewStatus: '',
|
||||
customerName: '',
|
||||
destinationCode: '',
|
||||
holdFlag: '',
|
||||
}
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
watch(() => filters.value.customerId, (customerId) => {
|
||||
if (customerId === selectedCustomerId.value) return
|
||||
selectedCustomerId.value = customerId
|
||||
page.value = 1
|
||||
tab.value = ''
|
||||
filters.value = { ...filters.value, warehouseId: '', deliveryWarehouseId: '' }
|
||||
resetDetail()
|
||||
detailOpen.value = false
|
||||
void loadWarehouseOptions()
|
||||
void loadInbounds()
|
||||
})
|
||||
watch(tab, () => { page.value = 1; void loadInbounds() })
|
||||
watch([page, pageSize], () => { void loadInbounds() })
|
||||
onMounted(() => { void loadCustomers(); void loadInbounds() })
|
||||
onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId += 1; customerRequestId += 1; warehouseRequestId += 1 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page inbound-orders-page">
|
||||
<QueryToolbar v-model="filters" class="surface-panel inbound-query-toolbar" ariaLabel="入库单筛选" :fields="queryFields" :search-on-reset="false" @search="applyFilters" @reset="resetFilters">
|
||||
<template #actions>
|
||||
<el-button :aria-expanded="showAdvancedFilters" @click="toggleAdvancedFilters">
|
||||
<SlidersHorizontal :size="16" />
|
||||
{{ showAdvancedFilters ? '收起筛选' : '更多筛选' }}
|
||||
<ChevronUp v-if="showAdvancedFilters" :size="15" />
|
||||
<ChevronDown v-else :size="15" />
|
||||
</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
|
||||
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
|
||||
<el-button link type="warning" :loading="customersLoading" @click="loadCustomers">重新加载客户列表</el-button>
|
||||
</el-alert>
|
||||
|
||||
<LoadErrorState v-if="loadError" class="inbound-load-error" :message="loadError.message" title="入库单暂时无法加载" @retry="loadInbounds"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
||||
<header class="table-toolbar">
|
||||
<nav class="inbound-tabs" role="tablist" aria-label="入库单处理状态">
|
||||
<button v-for="(item, index) in tabs" :key="item.value || 'all'" type="button" role="tab" class="inbound-tab" :class="{ 'is-active': tab === item.value }" :aria-selected="tab === item.value" :tabindex="tab === item.value ? 0 : -1" :title="item.description" @click="tab = item.value" @keydown="handleTabKeydown($event, index)">{{ item.label }}</button>
|
||||
</nav>
|
||||
<div class="table-toolbar-actions">
|
||||
<span class="result-summary">共 {{ result.total }} 条 · 所属企业 {{ selectedCustomerName }}</span>
|
||||
<el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
|
||||
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
||||
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
|
||||
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
|
||||
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||
<template #feeStatus="{ row }"><div class="status-stack"><span>{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</span><small v-if="(row as InboundOrderSummary).feeReviewStatus">审核:{{ text((row as InboundOrderSummary).feeReviewStatus) }}</small></div></template>
|
||||
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
|
||||
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
|
||||
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
|
||||
<template #loadingStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).loadingStatus, loadingStatusLabels) }}</template>
|
||||
<template #holdFlag="{ row }"><el-tag :type="(row as InboundOrderSummary).holdFlag ? 'danger' : 'info'" effect="plain">{{ (row as InboundOrderSummary).holdFlag ? '是' : '否' }}</el-tag></template>
|
||||
<template #currentHoldReason="{ row }">{{ text((row as InboundOrderSummary).currentHoldReason) }}</template>
|
||||
<template #containerNo="{ row }">{{ text((row as InboundOrderSummary).containerNo) }}</template>
|
||||
<template #cabinetNo="{ row }">{{ text((row as InboundOrderSummary).cabinetNo) }}</template>
|
||||
<template #companyName="{ row }">{{ text((row as InboundOrderSummary).companyName) }}</template>
|
||||
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template>
|
||||
<template #channelName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).channelName) }}</strong><span>{{ text((row as InboundOrderSummary).channelCode) }}</span></div></template>
|
||||
<template #taxMode="{ row }">{{ codeLabel((row as InboundOrderSummary).taxMode, taxModeLabels) }}</template>
|
||||
<template #containerMode="{ row }">{{ codeLabel((row as InboundOrderSummary).containerMode, containerModeLabels) }}</template>
|
||||
<template #customsInspection="{ row }">{{ (row as InboundOrderSummary).customsInspection === null ? '—' : (row as InboundOrderSummary).customsInspection ? '是' : '否' }}</template>
|
||||
<template #sailingAt="{ row }">{{ formatDate((row as InboundOrderSummary).sailingAt) }}</template>
|
||||
<template #portArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).portArrivalAt) }}</template>
|
||||
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
|
||||
<template #actualGrossWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}</template>
|
||||
<template #chargeableWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).chargeableWeightKg) }}</template>
|
||||
<template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template>
|
||||
<template #chargeableVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).chargeableVolumeCbm) }}</template>
|
||||
<template #palletCount="{ row }">{{ text((row as InboundOrderSummary).palletCount) }}</template>
|
||||
<template #locationCodes="{ row }">{{ text((row as InboundOrderSummary).locationCodes) }}</template>
|
||||
<template #volumeWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).volumeWeightKg) }}</template>
|
||||
<template #actualNetWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualNetWeightKg) }}</template>
|
||||
<template #destinationType="{ row }">{{ text((row as InboundOrderSummary).destinationType) }}</template>
|
||||
<template #destinationName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).destinationName) }}</strong><span>{{ text((row as InboundOrderSummary).destinationCode) }}</span></div></template>
|
||||
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
|
||||
<template #inboundedAt="{ row }">{{ formatDate((row as InboundOrderSummary).inboundedAt) }}</template>
|
||||
<template #expressCompanyName="{ row }">{{ text((row as InboundOrderSummary).expressCompanyName) }}</template>
|
||||
<template #expressTrackingNo="{ row }">{{ text((row as InboundOrderSummary).expressTrackingNo) }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDate((row as InboundOrderSummary).createdAt) }}</template>
|
||||
<template #empty><el-empty :description="listEmptyText" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
|
||||
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)">重新加载</el-button></section>
|
||||
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="detail.publicRemark" /></div>
|
||||
</AppDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-orders-page { min-width: 0; }
|
||||
.inbound-tabs { display: flex; max-width: 100%; min-width: 0; overflow-x: auto; padding: 4px; border: 1px solid var(--cc-border); border-radius: 8px; background: #f7f9fc; scrollbar-width: none; }
|
||||
.inbound-tabs::-webkit-scrollbar { display: none; }
|
||||
.inbound-tab { min-height: 44px; flex: 0 0 auto; padding: 8px 18px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--cc-muted); cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease; }
|
||||
.inbound-tab:hover { background: #fff; color: var(--cc-primary); }
|
||||
.inbound-tab:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
|
||||
.inbound-tab.is-active { border-color: #d8e0f0; background: #fff; color: var(--cc-primary); box-shadow: 0 1px 3px rgba(16, 24, 40, .08); font-weight: 650; }
|
||||
.inbound-load-error { margin-top: 12px; }
|
||||
.inbound-query-toolbar { border-radius: 8px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||
.table-toolbar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; min-height: 68px; padding: 10px 14px; border-bottom: 1px solid var(--cc-border); }
|
||||
.table-toolbar-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 14px; }
|
||||
.result-summary { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.table-toolbar :deep(.el-button) { min-height: 40px; margin-left: 0; }
|
||||
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
|
||||
.primary-cell strong, .primary-cell span, .status-stack span, .status-stack small { overflow-wrap: anywhere; }
|
||||
.primary-cell span, .status-stack span, .status-stack small { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-content { display: grid; gap: 24px; min-width: 0; }
|
||||
.detail-error { display: flex; min-height: 240px; align-items: flex-start; justify-content: center; flex-direction: column; }
|
||||
.detail-error strong { color: var(--cc-danger); font-size: 15px; }
|
||||
.detail-error p { margin: 7px 0; color: var(--cc-text); font-size: 13px; }
|
||||
.detail-error :deep(.el-button) { margin-top: 14px; }
|
||||
@media (max-width: 900px) { .table-toolbar { align-items: stretch; flex-direction: column; } .inbound-tabs { width: 100%; } .table-toolbar-actions { justify-content: space-between; } }
|
||||
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-toolbar { padding: 10px; } .table-toolbar-actions { gap: 10px; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-toolbar :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
|
||||
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
approveMeasurement: vi.fn(),
|
||||
getMeasurementReviewDetail: vi.fn(),
|
||||
listMeasurementReviewHistory: vi.fn(),
|
||||
rejectMeasurement: vi.fn(),
|
||||
releaseRejectedMeasurement: vi.fn(),
|
||||
}))
|
||||
|
||||
const detail = {
|
||||
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
|
||||
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
|
||||
measurementId: '70001', sourceType: 'DEVICE', measurementStatus: 'SUBMITTED', version: 4,
|
||||
originalDocumentNo: 'ORD-001', originalGrossWeightKg: 100, originalVolumeCbm: 2,
|
||||
actualPackageCount: 10, actualGrossWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物',
|
||||
items: [{ id: '1', itemNo: '001', boxSpecification: '60×40×30', packageCount: 10, lengthCm: 60, widthCm: 40, heightCm: 30, girthCm: 240, unitWeightKg: 11, unitVolumeCbm: 0.072, totalWeightKg: 110, totalVolumeCbm: 0.72, volumeWeightKg: 120.24, intelligentImageFileId: '30001', overLimitWarning: '周长超过渠道限制,请人工核验尺寸后再作出审核决定', outerLengthCm: 61, outerWidthCm: 41, outerHeightCm: 31, remark: '外箱存在轻微变形' }],
|
||||
weightResults: [{ id: '2', fileId: 'file-1', ocrProvider: 'OCR', ocrRawText: '设备屏幕原始识别内容 110.5 KG,包含一段需要自动换行展示的较长文本', ocrWeightValue: 110.5, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: 110, adjustedWeightUnit: 'KG', finalWeightKg: 110, recognitionStatus: 'CONFIRMED', adjustmentReason: '去除托盘重量后采用净值', adjustedBy: '20001', adjustedAt: '2026-08-04T09:58:00', confirmedBy: '20002', confirmedAt: '2026-08-04T10:00:00', version: 1 }],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('MeasurementReviewPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['firstleg:inbound:measurement-review', 'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail)
|
||||
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
})
|
||||
|
||||
it('loads and compares original, device and OCR data without exposing a normal fee action', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('原单申报')
|
||||
expect(wrapper.text()).toContain('设备实测')
|
||||
expect(wrapper.text()).toContain('称重 OCR')
|
||||
expect(wrapper.text()).toContain('外箱长 × 宽 × 高(cm)')
|
||||
expect(wrapper.text()).toContain('周长超过渠道限制')
|
||||
expect(wrapper.text()).toContain('OCR 原文:设备屏幕原始识别内容')
|
||||
expect(wrapper.text()).toContain('调整原因:去除托盘重量后采用净值')
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.text()).toContain('通过并完成入库')
|
||||
expect(wrapper.text()).not.toContain('确认费用')
|
||||
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
|
||||
})
|
||||
|
||||
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] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('异常放行')
|
||||
expect(wrapper.text()).toContain('重量图片不清晰')
|
||||
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = ['firstleg:inbound:measurement-review-history']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('异常放行'))).toBe(false)
|
||||
})
|
||||
|
||||
it('emits changed after a successful review write', async () => {
|
||||
vi.mocked(approveMeasurement).mockResolvedValue({ feeId: '1', feeNo: 'FEE-1', feeVersion: 1, feeStatus: 'SENT', inboundId: '90001', totalAmount: 100, currency: 'CNY', nextProcessStatus: 'INBOUNDED' })
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button')
|
||||
const approve = buttons.find((button) => button.text().includes('通过并完成入库'))
|
||||
expect(approve).toBeDefined()
|
||||
await approve?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('自动试算并发布费用'),
|
||||
'确认测量审核通过',
|
||||
expect.objectContaining({ confirmButtonText: '通过并完成入库' }),
|
||||
)
|
||||
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 4 })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
approveMeasurement,
|
||||
getMeasurementReviewDetail,
|
||||
listMeasurementReviewHistory,
|
||||
rejectMeasurement,
|
||||
releaseRejectedMeasurement,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('measurement review API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
})
|
||||
|
||||
it('keeps the measurement detail and review history prefixes from the backend controllers', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: { measurementId: '7' } })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' } })
|
||||
|
||||
await getMeasurementReviewDetail('10001', '90001')
|
||||
await listMeasurementReviewHistory('10001', '90001')
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/inbounds/90001/measurement', { params: { tenantId: '10001' } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurement-review-history', { params: { tenantId: '10001', page: 1, size: 20 } })
|
||||
})
|
||||
|
||||
it('normalizes string pagination and sends idempotency headers for review writes', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '3', pages: '1' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
|
||||
await expect(listMeasurementReviewHistory('10001', '90001', 2, 20)).resolves.toMatchObject({ page: 2, total: 3 })
|
||||
await approveMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4 }, 'approve-key')
|
||||
await rejectMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, 'reject-key')
|
||||
await releaseRejectedMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, 'release-key')
|
||||
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/90001/measurements/approve', { measurementId: '7', expectedVersion: 4 }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'approve-key' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurements/reject', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'reject-key' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/inbounds/90001/measurements/exception-release', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'release-key' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
ExceptionReleaseRequest,
|
||||
FeeSnapshotAfterApprove,
|
||||
MeasurementReviewDetail,
|
||||
MeasurementReviewHistoryPage,
|
||||
MeasurementReviewHistoryPayload,
|
||||
MeasurementReviewRequest,
|
||||
} from './types'
|
||||
|
||||
async function getData<T>(path: string, params: Record<string, unknown>) {
|
||||
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(path, { params })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
function idempotencyKey() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
return `review-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
async function postReview<T>(path: string, tenantId: string, body: unknown, key = idempotencyKey()) {
|
||||
const response = await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(path, body, {
|
||||
params: { tenantId },
|
||||
headers: { 'Idempotency-Key': key },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
function paginationNumber(value: number | string, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback
|
||||
}
|
||||
|
||||
export function getMeasurementReviewDetail(tenantId: string, inboundId: string) {
|
||||
return getData<MeasurementReviewDetail>(
|
||||
`/platform/v1/first-leg/inbounds/${inboundId}/measurement`,
|
||||
{ tenantId },
|
||||
)
|
||||
}
|
||||
|
||||
export async function listMeasurementReviewHistory(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
page = 1,
|
||||
size = 20,
|
||||
) {
|
||||
const result = await getData<MeasurementReviewHistoryPayload>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurement-review-history`,
|
||||
{ tenantId, page, size },
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
page: paginationNumber(result.page, page),
|
||||
size: paginationNumber(result.size, size),
|
||||
total: paginationNumber(result.total, 0),
|
||||
pages: paginationNumber(result.pages, 0),
|
||||
} satisfies MeasurementReviewHistoryPage
|
||||
}
|
||||
|
||||
export function approveMeasurement(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
request: MeasurementReviewRequest,
|
||||
key?: string,
|
||||
) {
|
||||
return postReview<FeeSnapshotAfterApprove>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurements/approve`,
|
||||
tenantId,
|
||||
request,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
export function rejectMeasurement(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
request: MeasurementReviewRequest,
|
||||
key?: string,
|
||||
) {
|
||||
return postReview<void>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurements/reject`,
|
||||
tenantId,
|
||||
request,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
export function releaseRejectedMeasurement(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
request: ExceptionReleaseRequest,
|
||||
key?: string,
|
||||
) {
|
||||
return postReview<void>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurements/exception-release`,
|
||||
tenantId,
|
||||
request,
|
||||
key,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as MeasurementReviewPanel } from './MeasurementReviewPanel.vue'
|
||||
export * from './api'
|
||||
export type * from './types'
|
||||
@@ -0,0 +1,123 @@
|
||||
export type MeasurementReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | string
|
||||
|
||||
export interface MeasurementReviewItem {
|
||||
id: string
|
||||
itemNo: string
|
||||
boxSpecification: string | null
|
||||
packageCount: number | null
|
||||
lengthCm: number | null
|
||||
widthCm: number | null
|
||||
heightCm: number | null
|
||||
girthCm: number | null
|
||||
unitWeightKg: number | null
|
||||
unitVolumeCbm: number | null
|
||||
totalWeightKg: number | null
|
||||
totalVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
intelligentImageFileId: string | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface MeasurementReviewWeightResult {
|
||||
id: string
|
||||
fileId: string | null
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
recognitionStatus: string | null
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: string | null
|
||||
adjustedAt: string | null
|
||||
confirmedBy: string | null
|
||||
confirmedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface MeasurementReviewDetail {
|
||||
inboundId: string
|
||||
inboundCode: string | null
|
||||
companyName: string | null
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
processStatus: string | null
|
||||
measureReviewStatus: MeasurementReviewStatus | null
|
||||
recommendedPalletType: string | null
|
||||
reviewReason: string | null
|
||||
reviewerId: string | null
|
||||
reviewerName: string | null
|
||||
measurementId: string
|
||||
sourceType: string | null
|
||||
measurementStatus: string | null
|
||||
version: number
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
items: MeasurementReviewItem[]
|
||||
weightResults: MeasurementReviewWeightResult[]
|
||||
}
|
||||
|
||||
export interface MeasurementReviewRecord {
|
||||
id: string
|
||||
tenantId: string
|
||||
inboundId: string
|
||||
measurementId: string
|
||||
reviewResult: string | null
|
||||
reviewReason: string | null
|
||||
exceptionRelease: boolean
|
||||
releaseReason: string | null
|
||||
allowRejectedConfig: boolean
|
||||
reviewerName: string | null
|
||||
reviewedAt: string | null
|
||||
}
|
||||
|
||||
export interface MeasurementReviewHistoryPage {
|
||||
records: MeasurementReviewRecord[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface MeasurementReviewHistoryPayload extends Omit<MeasurementReviewHistoryPage, 'page' | 'size' | 'total' | 'pages'> {
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages: number | string
|
||||
}
|
||||
|
||||
export interface MeasurementReviewRequest {
|
||||
measurementId: string
|
||||
expectedVersion: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ExceptionReleaseRequest {
|
||||
measurementId: string
|
||||
expectedVersion: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface FeeSnapshotAfterApprove {
|
||||
feeId: string | null
|
||||
feeNo: string | null
|
||||
feeVersion: number | null
|
||||
feeStatus: string | null
|
||||
inboundId: string | null
|
||||
totalAmount: number | null
|
||||
currency: string | null
|
||||
nextProcessStatus: string | null
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const inboundRoutes = [
|
||||
{
|
||||
path: 'inbounds',
|
||||
name: 'inbound-orders',
|
||||
component: () => import('./pages/InboundOrdersView.vue'),
|
||||
meta: {
|
||||
title: '入库单',
|
||||
description: '查询头程入库单并跟进测量、审核、费用和入库进度',
|
||||
permission: 'platform:firstleg:inbound:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'inbounds/:inboundId',
|
||||
name: 'inbound-order-detail',
|
||||
component: () => import('./pages/InboundOrderDetailView.vue'),
|
||||
meta: {
|
||||
title: '入库作业详情',
|
||||
description: '处理测量、称重、审核并查看费用和托盘库位结果',
|
||||
permission: 'platform:firstleg:inbound:view',
|
||||
},
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
downloadWeightImage,
|
||||
getWeightWorkspace,
|
||||
retryWeightRecognition,
|
||||
uploadWeightImage,
|
||||
} from './weightOcr'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
|
||||
ApiResponseError: class ApiResponseError extends Error {},
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('inbound weight OCR API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
})
|
||||
|
||||
it('loads the measurement workspace using the real firstleg path', async () => {
|
||||
const detail = { inboundId: '9007199254740993', weightResults: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(getWeightWorkspace('10001', '9007199254740993')).resolves.toEqual(detail)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/9007199254740993/measurement',
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads and retries against the file service without converting long IDs', async () => {
|
||||
const result = { fileId: '9007199254740995', recognitionStatus: 'SUCCESS' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
|
||||
const file = new File(['image'], 'weight.png', { type: 'image/png' })
|
||||
|
||||
await expect(uploadWeightImage('10001', '9007199254740993', file)).resolves.toEqual(result)
|
||||
const [uploadPath, body] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(uploadPath).toBe('/platform/v1/files/measurements/10001/9007199254740993')
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect((body as FormData).get('file')).toBe(file)
|
||||
|
||||
await retryWeightRecognition('10001', '9007199254740993', '9007199254740995')
|
||||
expect(http.post).toHaveBeenLastCalledWith(
|
||||
'/platform/v1/files/measurements/10001/9007199254740993/9007199254740995/recognize',
|
||||
)
|
||||
})
|
||||
|
||||
it('registers, corrects, and confirms a weight result using the implemented contracts', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } })
|
||||
|
||||
const createPayload = {
|
||||
fileId: '9007199254740995',
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: null,
|
||||
ocrWeightValue: 12.5,
|
||||
ocrWeightUnit: 'KG',
|
||||
confidence: 0.98,
|
||||
}
|
||||
await createWeightResult('10001', '20001', createPayload)
|
||||
expect(http.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results',
|
||||
createPayload,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
|
||||
const correction = { version: 0, adjustedWeightValue: 12.8, adjustedWeightUnit: 'KG' as const, adjustmentReason: '读数模糊' }
|
||||
await correctWeightResult('10001', '20001', '30001', correction)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results/30001',
|
||||
correction,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
|
||||
await confirmWeightResult('10001', '20001', '30001', { version: 1 })
|
||||
expect(http.post).toHaveBeenLastCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results/30001/confirm',
|
||||
{ version: 1 },
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads an authenticated image as a blob', async () => {
|
||||
const image = new Blob(['png'], { type: 'image/png' })
|
||||
vi.mocked(http.get).mockResolvedValue(image)
|
||||
await expect(downloadWeightImage('10001', '20001', '40001')).resolves.toBe(image)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/files/measurements/10001/20001/40001',
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import axios from 'axios'
|
||||
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
ConfirmWeightResultRequest,
|
||||
CorrectWeightResultRequest,
|
||||
CreateWeightResultRequest,
|
||||
WeightImageUploadResult,
|
||||
WeightResultResource,
|
||||
WeightWorkspaceResource,
|
||||
} from '../types'
|
||||
|
||||
const measurementBasePath = '/platform/v1/first-leg/inbounds'
|
||||
const imageBasePath = '/platform/v1/files/measurements'
|
||||
const maxErrorEnvelopeBytes = 1024 * 1024
|
||||
|
||||
function segment(value: string) {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
async function parseBlobError(blob: Blob, status?: number) {
|
||||
if (!blob.type.toLowerCase().includes('json')) return null
|
||||
if (blob.size > maxErrorEnvelopeBytes) {
|
||||
return new ApiResponseError('图片接口返回了超出限制的 JSON 响应', 'INVALID_IMAGE_RESPONSE', status)
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
|
||||
return new ApiResponseError(
|
||||
payload.code === '200' ? '图片接口未返回图片内容' : payload.message || '称重图片读取失败',
|
||||
payload.code === '200' ? 'INVALID_IMAGE_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'IMAGE_FAILED'),
|
||||
status,
|
||||
payload.data,
|
||||
payload.requestId,
|
||||
payload.traceId,
|
||||
)
|
||||
} catch {
|
||||
return new ApiResponseError('图片接口返回了无法识别的响应', 'INVALID_IMAGE_RESPONSE', status)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWeightWorkspace(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<WeightWorkspaceResource>, ApiEnvelope<WeightWorkspaceResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/measurement`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<WeightImageUploadResult>, ApiEnvelope<WeightImageUploadResult>>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}`,
|
||||
body,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function retryWeightRecognition(tenantId: string, inboundId: string, fileId: string) {
|
||||
const response = await http.post<ApiEnvelope<WeightImageUploadResult>, ApiEnvelope<WeightImageUploadResult>>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}/recognize`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function downloadWeightImage(tenantId: string, inboundId: string, fileId: string) {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
const responseError = await parseBlobError(blob, 200)
|
||||
if (responseError) throw responseError
|
||||
return blob
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const responseError = await parseBlobError(error.response.data, error.response.status)
|
||||
if (responseError) throw responseError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
payload: CreateWeightResultRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function correctWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
resultId: string,
|
||||
payload: CorrectWeightResultRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function confirmWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
resultId: string,
|
||||
payload: ConfirmWeightResultRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}/confirm`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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/weight/components/WeightOcrPanel.vue'), 'utf8')
|
||||
|
||||
describe('WeightOcrPanel contract', () => {
|
||||
it('uses customer language at the page boundary while retaining a compatibility input', () => {
|
||||
expect(source).toContain("defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()")
|
||||
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
|
||||
expect(source).toContain("defineEmits<{ changed: [] }>()")
|
||||
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
|
||||
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
||||
expect(template).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('covers upload, preview, OCR retry, correction, confirmation, and server totals', () => {
|
||||
expect(source).toContain('uploadWeightImage')
|
||||
expect(source).toContain('downloadWeightImage')
|
||||
expect(source).toContain('retryWeightRecognition')
|
||||
expect(source).toContain('correctWeightResult')
|
||||
expect(source).toContain('confirmWeightResult')
|
||||
expect(source).toContain('实际总毛重(服务端汇总)')
|
||||
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
|
||||
expect(source).toContain('workspace.value?.actualGrossWeightKg')
|
||||
})
|
||||
|
||||
it('matches backend image limits and preserves missing OCR text honestly', () => {
|
||||
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
|
||||
expect(source).toContain('5 * 1024 * 1024')
|
||||
expect(source).toContain('图片服务未返回 OCR 原文')
|
||||
expect(source).toContain("adjustmentReason: 'OCR 重试识别结果'")
|
||||
})
|
||||
|
||||
it('gates every action with the implemented permissions and editable statuses', () => {
|
||||
expect(source).toContain("platform:firstleg:inbound:measure:view")
|
||||
expect(source).toContain("platform:firstleg:inbound:weight:edit")
|
||||
expect(source).toContain("platform:firstleg:inbound:weight:confirm")
|
||||
expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
|
||||
})
|
||||
|
||||
it('provides error tracking, empty, loading, and narrow-screen states', () => {
|
||||
expect(source).toContain('<ApiErrorMeta')
|
||||
expect(source).toContain('<LoadErrorState')
|
||||
expect(source).toContain('尚未上传称重图片')
|
||||
expect(source).toContain('v-loading="loading"')
|
||||
expect(source).toContain('@media (max-width: 360px)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,530 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
FileImage,
|
||||
ImagePlus,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
} 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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
downloadWeightImage,
|
||||
getWeightWorkspace,
|
||||
retryWeightRecognition,
|
||||
uploadWeightImage,
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
|
||||
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const correctionFormRef = ref<FormInstance>()
|
||||
const workspace = ref<WeightWorkspaceResource | null>(null)
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const activeAction = ref('')
|
||||
const correctionOpen = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<ApiErrorDetails | null>(null)
|
||||
const previewUrl = ref('')
|
||||
const correction = reactive({
|
||||
resultId: '',
|
||||
version: 0,
|
||||
value: null as number | null,
|
||||
unit: 'KG' as 'KG' | 'LB',
|
||||
reason: '',
|
||||
})
|
||||
let loadSequence = 0
|
||||
|
||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit'))
|
||||
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
|
||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
|
||||
const rows = computed(() => workspace.value?.weightResults ?? [])
|
||||
const confirmedCount = computed(() => rows.value.filter((row) => row.recognitionStatus === 'CONFIRMED').length)
|
||||
const pendingCount = computed(() => rows.value.length - confirmedCount.value)
|
||||
const confirmedTotal = computed(() => workspace.value?.actualGrossWeightKg ?? null)
|
||||
|
||||
const correctionRules: FormRules<typeof correction> = {
|
||||
value: [
|
||||
{ required: true, message: '请输入有效重量', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => Number(value) > 0 ? callback() : callback(new Error('重量必须大于 0')),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
unit: [{ required: true, message: '请选择重量单位', trigger: 'change' }],
|
||||
reason: [{ required: true, message: '请填写人工修正原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function formatWeight(value: number | null, unit = 'KG') {
|
||||
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
||||
}
|
||||
|
||||
function formatConfidence(value: number | null) {
|
||||
return value == null ? '未返回' : `${(Number(value) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
if (status === 'CONFIRMED') return { label: '已确认', type: 'success' as const }
|
||||
if (status === 'PENDING_CONFIRM') return { label: '待确认', type: 'warning' as const }
|
||||
if (status === 'NEEDS_MANUAL') return { label: '需人工处理', type: 'danger' as const }
|
||||
return { label: status || '未知', type: 'info' as const }
|
||||
}
|
||||
|
||||
function actionKey(action: string, id: string) {
|
||||
return `${action}:${id}`
|
||||
}
|
||||
|
||||
function isActionLoading(action: string, id: string) {
|
||||
return activeAction.value === actionKey(action, id)
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
||||
workspace.value = null
|
||||
return
|
||||
}
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await getWeightWorkspace(customerId.value, props.inboundId)
|
||||
if (sequence === loadSequence) workspace.value = result
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
workspace.value = null
|
||||
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFile() {
|
||||
if (!editable.value) {
|
||||
ElMessage.warning('当前入库状态不允许上传称重图片')
|
||||
return
|
||||
}
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function validateImage(file: File) {
|
||||
if (!['image/jpeg', 'image/png', 'image/bmp'].includes(file.type)) return '称重图片仅支持 JPG、PNG、BMP'
|
||||
if (file.size > 5 * 1024 * 1024) return '称重图片不能超过 5MB'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || uploading.value) return
|
||||
const validationError = validateImage(file)
|
||||
if (validationError) {
|
||||
ElMessage.warning(validationError)
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
|
||||
await createWeightResult(customerId.value, props.inboundId, {
|
||||
fileId: String(upload.fileId),
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: null,
|
||||
ocrWeightValue: upload.weightValue,
|
||||
ocrWeightUnit: upload.weightUnit,
|
||||
confidence: upload.confidence,
|
||||
})
|
||||
ElMessage.success(upload.recognitionStatus === 'FAILED'
|
||||
? '图片已保存,OCR 未识别出重量,请人工修正或重试'
|
||||
: '称重图片已上传并完成识别')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCorrection(row: WeightResultResource) {
|
||||
correction.resultId = row.id
|
||||
correction.version = row.version
|
||||
correction.value = row.adjustedWeightValue ?? row.ocrWeightValue
|
||||
correction.unit = (row.adjustedWeightUnit ?? row.ocrWeightUnit) === 'LB' ? 'LB' : 'KG'
|
||||
correction.reason = ''
|
||||
correctionOpen.value = true
|
||||
void correctionFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submitCorrection() {
|
||||
if (!await correctionFormRef.value?.validate().catch(() => false) || correction.value == null) return
|
||||
const key = actionKey('correct', correction.resultId)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await correctWeightResult(customerId.value, props.inboundId, correction.resultId, {
|
||||
version: correction.version,
|
||||
adjustedWeightValue: correction.value,
|
||||
adjustedWeightUnit: correction.unit,
|
||||
adjustmentReason: correction.reason.trim(),
|
||||
})
|
||||
correctionOpen.value = false
|
||||
ElMessage.success('重量修正已保存,请确认后计入总重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '重量修正失败,请刷新后重试')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmResult(row: WeightResultResource) {
|
||||
const key = actionKey('confirm', row.id)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await confirmWeightResult(customerId.value, props.inboundId, row.id, { version: row.version })
|
||||
ElMessage.success('重量已确认并计入实际总重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '重量确认失败,请刷新后重试')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function retryOcr(row: WeightResultResource) {
|
||||
const key = actionKey('retry', row.id)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
const result = await retryWeightRecognition(customerId.value, props.inboundId, row.fileId)
|
||||
if (result.weightValue == null || !result.weightUnit) {
|
||||
throw new Error(result.error || 'OCR 仍未识别出有效重量,可改用人工修正')
|
||||
}
|
||||
await correctWeightResult(customerId.value, props.inboundId, row.id, {
|
||||
version: row.version,
|
||||
adjustedWeightValue: result.weightValue,
|
||||
adjustedWeightUnit: result.weightUnit.toUpperCase().startsWith('LB') ? 'LB' : 'KG',
|
||||
adjustmentReason: 'OCR 重试识别结果',
|
||||
})
|
||||
ElMessage.success('OCR 重试成功,请核对并确认重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, 'OCR 重试失败,可稍后重试或人工修正')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function releasePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
async function openPreview(row: WeightResultResource) {
|
||||
releasePreview()
|
||||
previewError.value = null
|
||||
previewOpen.value = true
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
previewError.value = getApiErrorDetails(error, '称重图片读取失败,请稍后重试')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerId.value, props.inboundId, canView.value] as const,
|
||||
() => {
|
||||
++loadSequence
|
||||
workspace.value = null
|
||||
loadError.value = null
|
||||
actionError.value = null
|
||||
correctionOpen.value = false
|
||||
previewOpen.value = false
|
||||
releasePreview()
|
||||
void loadWorkspace()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(previewOpen, (open) => { if (!open) releasePreview() })
|
||||
onBeforeUnmount(releasePreview)
|
||||
|
||||
defineExpose({ reload: loadWorkspace })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="weight-workspace" aria-labelledby="weight-ocr-title">
|
||||
<header class="weight-heading">
|
||||
<div>
|
||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片与 OCR</h3>
|
||||
<p>逐张核对识别值并确认,服务端汇总所有已确认图片的实际总重量。</p>
|
||||
</div>
|
||||
<div class="weight-heading__actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/bmp"
|
||||
aria-label="选择称重图片"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canEdit"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="loading || !editable"
|
||||
@click="chooseFile"
|
||||
><ImagePlus :size="16" />上传称重图片</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||
当前账号缺少入库测量查看权限。
|
||||
</el-alert>
|
||||
|
||||
<template v-else>
|
||||
<LoadErrorState
|
||||
v-if="loadError"
|
||||
title="称重数据加载失败"
|
||||
:message="loadError.message"
|
||||
:loading="loading"
|
||||
compact
|
||||
@retry="loadWorkspace"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else>
|
||||
<div class="weight-metrics" aria-live="polite" :aria-busy="loading">
|
||||
<div><span>实际总毛重(服务端汇总)</span><strong>{{ formatWeight(confirmedTotal) }}</strong></div>
|
||||
<div><span>已确认图片</span><strong>{{ confirmedCount }}</strong></div>
|
||||
<div><span>待处理图片</span><strong :class="{ 'is-warning': pendingCount > 0 }">{{ pendingCount }}</strong></div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
|
||||
当前入库状态为 {{ workspace.processStatus }},称重数据仅供查看。
|
||||
</el-alert>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="actionError"
|
||||
title="操作未完成"
|
||||
:message="actionError.message"
|
||||
:show-retry="false"
|
||||
compact
|
||||
class="weight-action-error"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="actionError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<div v-loading="loading" class="weight-list" aria-live="polite">
|
||||
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
||||
<FileImage :size="28" aria-hidden="true" />
|
||||
<strong>尚未上传称重图片</strong>
|
||||
<p>上传 JPG、PNG 或 BMP 图片,单张不超过 5MB。</p>
|
||||
</div>
|
||||
|
||||
<article v-for="(row, index) in rows" :key="row.id" class="weight-row">
|
||||
<div class="weight-row__identity">
|
||||
<span class="weight-row__icon"><FileImage :size="18" aria-hidden="true" /></span>
|
||||
<div>
|
||||
<strong>称重图片序号 #{{ index + 1 }}</strong>
|
||||
<small>文件 ID {{ row.fileId }} · {{ row.ocrProvider || 'OCR 服务未标识' }}</small>
|
||||
</div>
|
||||
<el-tag :type="statusMeta(row.recognitionStatus).type" effect="light">
|
||||
{{ statusMeta(row.recognitionStatus).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<dl class="weight-row__facts">
|
||||
<div><dt>OCR 原值</dt><dd>{{ formatWeight(row.ocrWeightValue, row.ocrWeightUnit || '') }}</dd></div>
|
||||
<div><dt>置信度</dt><dd>{{ formatConfidence(row.confidence) }}</dd></div>
|
||||
<div><dt>人工修正</dt><dd>{{ formatWeight(row.adjustedWeightValue, row.adjustedWeightUnit || '') }}</dd></div>
|
||||
<div><dt>最终计入</dt><dd class="weight-final">{{ formatWeight(row.finalWeightKg) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="weight-row__raw">
|
||||
<span>OCR 原文</span>
|
||||
<p>{{ row.ocrRawText || '图片服务未返回 OCR 原文' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="row.adjustmentReason || row.confirmedAt" class="weight-row__audit">
|
||||
<span v-if="row.adjustmentReason">修正原因:{{ row.adjustmentReason }}</span>
|
||||
<span v-if="row.confirmedAt">确认时间:{{ formatTime(row.confirmedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<footer class="weight-row__actions">
|
||||
<el-button link type="primary" @click="openPreview(row)"><Eye :size="15" />查看图片</el-button>
|
||||
<el-button
|
||||
v-if="canEdit && editable && row.recognitionStatus !== 'CONFIRMED'"
|
||||
link
|
||||
type="primary"
|
||||
:loading="isActionLoading('retry', row.id)"
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="retryOcr(row)"
|
||||
><RefreshCw :size="15" />重试 OCR</el-button>
|
||||
<el-button
|
||||
v-if="canEdit && editable"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="openCorrection(row)"
|
||||
><Pencil :size="15" />人工修正</el-button>
|
||||
<el-button
|
||||
v-if="canConfirm && editable && row.recognitionStatus !== 'CONFIRMED'"
|
||||
link
|
||||
type="success"
|
||||
:loading="isActionLoading('confirm', row.id)"
|
||||
:disabled="Boolean(activeAction) || row.recognitionStatus === 'NEEDS_MANUAL'"
|
||||
@click="confirmResult(row)"
|
||||
><CheckCircle2 :size="15" />确认重量</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<AppDialog
|
||||
v-model="correctionOpen"
|
||||
title="人工修正重量"
|
||||
confirm-text="保存修正"
|
||||
:confirm-loading="activeAction.startsWith('correct:')"
|
||||
@confirm="submitCorrection"
|
||||
>
|
||||
<el-form ref="correctionFormRef" :model="correction" :rules="correctionRules" label-position="top">
|
||||
<div class="correction-grid">
|
||||
<el-form-item label="修正重量" prop="value">
|
||||
<el-input-number v-model="correction.value" :min="0.001" :precision="3" :controls="false" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-segmented v-model="correction.unit" :options="['KG', 'LB']" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="修正原因" prop="reason">
|
||||
<el-input
|
||||
v-model="correction.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="说明读数模糊、单位识别错误等具体原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="previewOpen"
|
||||
title="称重图片"
|
||||
:loading="previewLoading"
|
||||
:show-footer="false"
|
||||
width="min(760px, calc(100vw - 28px))"
|
||||
>
|
||||
<LoadErrorState
|
||||
v-if="previewError"
|
||||
title="图片读取失败"
|
||||
:message="previewError.message"
|
||||
:show-retry="false"
|
||||
compact
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="previewError" /></template>
|
||||
</LoadErrorState>
|
||||
<img v-else-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weight-workspace { min-width: 0; color: var(--el-text-color-primary); }
|
||||
.weight-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
|
||||
.weight-heading h3 svg { color: var(--cc-primary); }
|
||||
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.weight-heading__actions { flex: 0 0 auto; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
.weight-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 18px 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #f8fafc; }
|
||||
.weight-metrics > div { min-width: 0; padding: 15px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.weight-metrics > div:last-child { border-right: 0; }
|
||||
.weight-metrics span { display: block; margin-bottom: 5px; color: var(--cc-muted); font-size: 11px; }
|
||||
.weight-metrics strong { font-size: 20px; line-height: 1.2; }
|
||||
.weight-metrics strong.is-warning { color: #b54708; }
|
||||
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
|
||||
.weight-list { min-height: 120px; }
|
||||
.weight-empty { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 8px; padding: 24px; color: var(--cc-muted); text-align: center; }
|
||||
.weight-empty svg { color: #98a2b3; }
|
||||
.weight-empty strong { color: #344054; font-size: 14px; }
|
||||
.weight-empty p { margin: 0; font-size: 12px; }
|
||||
.weight-row { padding: 18px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-row:last-child { border-bottom: 0; }
|
||||
.weight-row__identity { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
|
||||
.weight-row__icon { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 7px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.weight-row__identity strong, .weight-row__identity small { display: block; min-width: 0; overflow-wrap: anywhere; }
|
||||
.weight-row__identity strong { font-size: 13px; }
|
||||
.weight-row__identity small { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
|
||||
.weight-row__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 14px 0 0; background: var(--cc-border); }
|
||||
.weight-row__facts > div { min-width: 0; padding: 11px 12px; background: #f8fafc; }
|
||||
.weight-row__facts dt { color: var(--cc-muted); font-size: 10px; }
|
||||
.weight-row__facts dd { margin: 4px 0 0; font-size: 12px; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.weight-row__facts .weight-final { color: #067647; }
|
||||
.weight-row__raw { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 10px; margin-top: 12px; font-size: 11px; line-height: 1.55; }
|
||||
.weight-row__raw span { color: var(--cc-muted); }
|
||||
.weight-row__raw p { margin: 0; color: #475467; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.weight-row__audit { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
.weight-row__actions { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 12px; }
|
||||
.weight-row__actions :deep(.el-button) { margin-left: 0; }
|
||||
.correction-grid { display: grid; grid-template-columns: minmax(0, 1fr) 150px; gap: 16px; }
|
||||
.correction-grid :deep(.el-input-number), .correction-grid :deep(.el-segmented) { width: 100%; }
|
||||
.weight-preview { display: block; max-width: 100%; max-height: min(68vh, 720px); margin: 0 auto; object-fit: contain; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.weight-heading { flex-direction: column; }
|
||||
.weight-heading__actions, .weight-heading__actions :deep(.el-button) { width: 100%; }
|
||||
.weight-heading__actions :deep(.el-button), .weight-row__actions :deep(.el-button) { min-height: 44px; }
|
||||
.weight-metrics { grid-template-columns: 1fr; }
|
||||
.weight-metrics > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-metrics > div:last-child { border-bottom: 0; }
|
||||
.weight-row__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__raw { grid-template-columns: 1fr; gap: 4px; }
|
||||
.weight-row__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
|
||||
.correction-grid { grid-template-columns: 1fr; gap: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.weight-row__identity { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.weight-row__identity > .el-tag { grid-column: 2; justify-self: start; }
|
||||
.weight-row__facts, .weight-row__actions { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as WeightOcrPanel } from './components/WeightOcrPanel.vue'
|
||||
export type { WeightResultResource, WeightWorkspaceResource } from './types'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export type WeightRecognitionStatus = 'PENDING_CONFIRM' | 'NEEDS_MANUAL' | 'CONFIRMED' | string
|
||||
|
||||
export interface WeightResultResource {
|
||||
id: string
|
||||
fileId: string
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
recognitionStatus: WeightRecognitionStatus
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: string | null
|
||||
adjustedAt: string | null
|
||||
confirmedBy: string | null
|
||||
confirmedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface WeightWorkspaceResource {
|
||||
inboundId: string
|
||||
inboundCode: string
|
||||
processStatus: string
|
||||
actualGrossWeightKg: number | null
|
||||
weightResults: WeightResultResource[]
|
||||
}
|
||||
|
||||
export interface WeightImageUploadResult {
|
||||
fileId: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
recognitionStatus: string
|
||||
weightValue: number | null
|
||||
weightUnit: string | null
|
||||
confidence: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface CreateWeightResultRequest {
|
||||
fileId: string
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
}
|
||||
|
||||
export interface CorrectWeightResultRequest {
|
||||
version: number
|
||||
adjustedWeightValue: number
|
||||
adjustedWeightUnit: 'KG' | 'LB'
|
||||
adjustmentReason: string
|
||||
}
|
||||
|
||||
export interface ConfirmWeightResultRequest {
|
||||
version: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user