This commit is contained in:
2026-08-04 21:01:39 +08:00
parent 88bd939170
commit 5cb1ca307d
77 changed files with 9914 additions and 131 deletions
@@ -0,0 +1,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>
+86
View File
@@ -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',
},
})
})
})
+127
View File
@@ -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))
}
+153
View File
@@ -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>
+73
View File
@@ -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)
})
})
+35
View File
@@ -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)}`
}
+13
View File
@@ -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'
+54
View File
@@ -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' } },
)
})
})
+46
View File
@@ -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'
+131
View File
@@ -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>
+47
View File
@@ -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' } })
})
})
+100
View File
@@ -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,
)
}
+3
View File
@@ -0,0 +1,3 @@
export { default as MeasurementReviewPanel } from './MeasurementReviewPanel.vue'
export * from './api'
export type * from './types'
+123
View File
@@ -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
}
+24
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
export { default as WeightOcrPanel } from './components/WeightOcrPanel.vue'
export type { WeightResultResource, WeightWorkspaceResource } from './types'
+62
View File
@@ -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
}