fix(admin): simplify booking documents and refine measurement review
This commit is contained in:
@@ -69,6 +69,7 @@ describe('InboundTaskList measurement scope', () => {
|
|||||||
expect(source).toContain(':show-actions="false"')
|
expect(source).toContain(':show-actions="false"')
|
||||||
expect(source).toContain('<template #footer="{ close }">')
|
expect(source).toContain('<template #footer="{ close }">')
|
||||||
expect(source).toContain('reviewPanelRef?.confirmApprove()')
|
expect(source).toContain('reviewPanelRef?.confirmApprove()')
|
||||||
|
expect(source).not.toContain('修改重量')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads only active customers and exposes a searchable name-only select', () => {
|
it('loads only active customers and exposes a searchable name-only select', () => {
|
||||||
|
|||||||
@@ -469,7 +469,6 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
|||||||
<div class="review-dialog-footer">
|
<div class="review-dialog-footer">
|
||||||
<el-button :disabled="reviewNavigationLoading" @click="close">关闭</el-button>
|
<el-button :disabled="reviewNavigationLoading" @click="close">关闭</el-button>
|
||||||
<template v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview">
|
<template v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview">
|
||||||
<el-button type="primary" plain @click="reviewPanelRef?.enableWeightCorrection()">修改重量</el-button>
|
|
||||||
<el-button type="primary" :loading="reviewPanelRef?.actionLoading === 'approve' || reviewNavigationLoading" :disabled="reviewApproved" @click="reviewPanelRef?.confirmApprove()">{{ reviewApproved ? '审核已通过' : reviewPanelRef?.approvalButtonLabel }}</el-button>
|
<el-button type="primary" :loading="reviewPanelRef?.actionLoading === 'approve' || reviewNavigationLoading" :disabled="reviewApproved" @click="reviewPanelRef?.confirmApprove()">{{ reviewApproved ? '审核已通过' : reviewPanelRef?.approvalButtonLabel }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,13 +62,19 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps customer context and pending actions in one sticky toolbar while reviewing long content', async () => {
|
it('keeps context in a scrolling toolbar and moves weight actions and totals to the weight details', async () => {
|
||||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const toolbar = wrapper.get('.review-toolbar')
|
const toolbar = wrapper.get('.review-toolbar')
|
||||||
expect(toolbar.text()).toContain('待审核')
|
expect(toolbar.text()).toContain('待审核')
|
||||||
expect(toolbar.text()).toContain('修改重量')
|
expect(toolbar.text()).not.toContain('修改重量')
|
||||||
|
const weightHeading = wrapper.get('.review-weight-items header')
|
||||||
|
expect(weightHeading.text()).toContain('修改重量')
|
||||||
|
expect(weightHeading.text()).toContain('实测毛重 120 kg')
|
||||||
|
expect(weightHeading.text()).toContain('货物重量 110 kg')
|
||||||
|
expect(weightHeading.find('.lucide-scale').exists()).toBe(true)
|
||||||
|
expect(weightHeading.find('.lucide-pencil').exists()).toBe(true)
|
||||||
expect(toolbar.findAll('button').map(button => button.text())).toContain('通过')
|
expect(toolbar.findAll('button').map(button => button.text())).toContain('通过')
|
||||||
expect(toolbar.text()).toContain('刷新')
|
expect(toolbar.text()).toContain('刷新')
|
||||||
expect(toolbar.text()).toContain('客户:柏悦客户')
|
expect(toolbar.text()).toContain('客户:柏悦客户')
|
||||||
@@ -83,7 +89,7 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
expect(wrapper.text()).not.toContain('审核历史')
|
expect(wrapper.text()).not.toContain('审核历史')
|
||||||
expect(listMeasurementReviewHistory).not.toHaveBeenCalled()
|
expect(listMeasurementReviewHistory).not.toHaveBeenCalled()
|
||||||
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
||||||
expect(source).toContain('position: sticky')
|
expect(source).not.toContain('position: sticky')
|
||||||
expect(source).toContain('review-toolbar__context')
|
expect(source).toContain('review-toolbar__context')
|
||||||
expect(source.indexOf('class="review-toolbar"')).toBeLessThan(source.indexOf('<WeightOcrPanel'))
|
expect(source.indexOf('class="review-toolbar"')).toBeLessThan(source.indexOf('<WeightOcrPanel'))
|
||||||
expect(source).not.toContain('reasonDialog')
|
expect(source).not.toContain('reasonDialog')
|
||||||
@@ -193,7 +199,6 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
|
|
||||||
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
|
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
|
||||||
expect(weightPanel.props('reviewMode')).toBe(true)
|
expect(weightPanel.props('reviewMode')).toBe(true)
|
||||||
expect(weightPanel.props('tableMode')).toBe(true)
|
|
||||||
expect(weightPanel.props('correctionMode')).toBe(false)
|
expect(weightPanel.props('correctionMode')).toBe(false)
|
||||||
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
|
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
|
||||||
expect(weightPanel.props('correctionMode')).toBe(true)
|
expect(weightPanel.props('correctionMode')).toBe(true)
|
||||||
@@ -229,7 +234,7 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
expect(wrapper.find('.review-toolbar__actions').exists()).toBe(false)
|
expect(wrapper.find('.review-toolbar__actions').exists()).toBe(false)
|
||||||
expect(wrapper.get('.review-toolbar__context').text()).toContain('客户:柏悦客户')
|
expect(wrapper.get('.review-toolbar__context').text()).toContain('客户:柏悦客户')
|
||||||
expect(wrapper.vm.approvalButtonLabel).toBe('通过')
|
expect(wrapper.vm.approvalButtonLabel).toBe('通过')
|
||||||
wrapper.vm.enableWeightCorrection()
|
await wrapper.get('.review-weight-summary button').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.getComponent({ name: 'WeightOcrPanel' }).props('correctionMode')).toBe(true)
|
expect(wrapper.getComponent({ name: 'WeightOcrPanel' }).props('correctionMode')).toBe(true)
|
||||||
await wrapper.vm.confirmApprove()
|
await wrapper.vm.confirmApprove()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
|||||||
import '@/styles/adminWorkbench.css'
|
import '@/styles/adminWorkbench.css'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Check, Pencil, RotateCcw } from '@lucide/vue'
|
import { Check, Pencil, RotateCcw, Ruler, Scale } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
@@ -13,6 +13,7 @@ import { downloadInboundMeasurementImage } from '../measurement/api'
|
|||||||
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
|
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
|
||||||
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||||
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
||||||
|
import type { WeightResultResource } from '../weight/types'
|
||||||
import { approveMeasurement, getMeasurementReviewDetail } from './api'
|
import { approveMeasurement, getMeasurementReviewDetail } from './api'
|
||||||
import type { MeasurementReviewDetail } from './types'
|
import type { MeasurementReviewDetail } from './types'
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ const loading = ref(false)
|
|||||||
const actionLoading = ref('')
|
const actionLoading = ref('')
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const correctionMode = ref(false)
|
const correctionMode = ref(false)
|
||||||
|
const weightPanelRef = ref<InstanceType<typeof WeightOcrPanel> | null>(null)
|
||||||
const requestSerial = ref(0)
|
const requestSerial = ref(0)
|
||||||
const imagePreviewOpen = ref(false)
|
const imagePreviewOpen = ref(false)
|
||||||
const imagePreviewTitle = ref('测量图片')
|
const imagePreviewTitle = ref('测量图片')
|
||||||
@@ -125,7 +127,7 @@ async function loadReview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
await loadReview()
|
await Promise.all([loadReview(), weightPanelRef.value?.reload()])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWeightChanged() {
|
async function handleWeightChanged() {
|
||||||
@@ -175,7 +177,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
onBeforeUnmount(releaseImagePreview)
|
onBeforeUnmount(releaseImagePreview)
|
||||||
|
|
||||||
defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPending, canReview, actionLoading })
|
defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, actionLoading })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -183,7 +185,6 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
|
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
|
||||||
<div class="review-toolbar" aria-label="测量审核操作">
|
<div class="review-toolbar" aria-label="测量审核操作">
|
||||||
<div v-if="showActions" class="review-toolbar__actions">
|
<div v-if="showActions" class="review-toolbar__actions">
|
||||||
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
|
|
||||||
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
|
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="review-toolbar__status">
|
<div class="review-toolbar__status">
|
||||||
@@ -220,18 +221,13 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
|
|
||||||
<section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title">
|
<section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title">
|
||||||
<header class="review-section-heading density-dialog-section__title">
|
<header class="review-section-heading density-dialog-section__title">
|
||||||
<div>
|
<h3 id="review-items-title" class="review-detail-heading"><Ruler :size="18" aria-hidden="true" />箱规测量明细</h3>
|
||||||
<h3 id="review-items-title">箱规测量明细</h3>
|
|
||||||
</div>
|
|
||||||
<span>{{ measurementCount }} 条</span>
|
<span>{{ measurementCount }} 条</span>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="detail.items.length" class="review-table-scroll">
|
<div v-if="detail.items.length" class="review-table-scroll">
|
||||||
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
|
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
|
||||||
<el-table-column type="index" label="#" width="44" align="center" />
|
<el-table-column type="index" label="#" width="44" align="center" />
|
||||||
<el-table-column label="箱号" width="160" show-overflow-tooltip>
|
<el-table-column label="测量图片" width="112" align="center">
|
||||||
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="测量图片" width="98" align="center">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<MeasurementEvidenceThumbnail
|
<MeasurementEvidenceThumbnail
|
||||||
:customer-id="customerId"
|
:customer-id="customerId"
|
||||||
@@ -243,6 +239,9 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="箱号" width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="尺寸 长×宽×高(cm)" min-width="200" align="center">
|
<el-table-column label="尺寸 长×宽×高(cm)" min-width="200" align="center">
|
||||||
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -258,7 +257,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
<el-table-column label="体积重(kg)" width="96" align="right">
|
<el-table-column label="体积重(kg)" width="96" align="right">
|
||||||
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
|
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="测量时间" width="190" class-name="review-time-cell">
|
<el-table-column label="测量时间" width="220" class-name="review-time-cell">
|
||||||
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
|
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -267,34 +266,45 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title">
|
<section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title">
|
||||||
<header class="review-section-heading density-dialog-section__title"><h3 id="review-weight-title">称重明细</h3><span>{{ weightCount }} 条</span></header>
|
<header class="review-section-heading density-dialog-section__title">
|
||||||
<div v-if="weightCount" class="review-table-scroll">
|
<h3 id="review-weight-title" class="review-detail-heading"><Scale :size="18" aria-hidden="true" />称重明细</h3>
|
||||||
<el-table :data="detail.weightResults" border stripe table-layout="fixed" class="review-table review-weight-table">
|
<div class="review-weight-summary">
|
||||||
<el-table-column type="index" label="#" width="44" align="center" />
|
<span>实测毛重 <strong>{{ formatNumber(detail.actualGrossWeightKg) }}</strong> kg</span>
|
||||||
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openImagePreview(row.fileId, row.palletCode || '托盘')" /></template></el-table-column>
|
<span>货物重量 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
|
||||||
<el-table-column prop="palletCode" label="托盘编码" min-width="120" />
|
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
|
||||||
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
|
<span>{{ weightCount }} 条</span>
|
||||||
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
|
</div>
|
||||||
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
|
</header>
|
||||||
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
|
<WeightOcrPanel
|
||||||
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
|
ref="weightPanelRef"
|
||||||
<el-table-column label="称重时间" min-width="150"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
|
review-mode
|
||||||
</el-table>
|
:correction-mode="correctionMode && isPending && canReview"
|
||||||
</div>
|
:customer-id="customerId"
|
||||||
<el-empty v-else description="暂无称重明细" :image-size="48" />
|
:inbound-id="inboundId"
|
||||||
|
@changed="handleWeightChanged"
|
||||||
|
>
|
||||||
|
<template #heading><span id="weight-ocr-title" class="sr-only">称重图片预览与修正</span></template>
|
||||||
|
<template #results="{ rows, openPreview, canCorrect, busy }">
|
||||||
|
<div v-if="rows.length" class="review-table-scroll">
|
||||||
|
<el-table :data="rows" border stripe table-layout="fixed" class="review-table review-weight-table">
|
||||||
|
<el-table-column v-if="canCorrect" label="操作" width="100">
|
||||||
|
<template #default="{ row, $index }"><el-button v-if="row.recognitionStatus === 'CONFIRMED'" link type="primary" :aria-label="`修改重量 ${$index + 1}`" :disabled="busy" @click="openPreview(row as WeightResultResource)">修改重量</el-button><span v-else>—</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column type="index" label="#" width="44" align="center" />
|
||||||
|
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openPreview(row as WeightResultResource)" /></template></el-table-column>
|
||||||
|
<el-table-column prop="palletCode" label="托盘编码" width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
|
||||||
|
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
|
||||||
|
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
|
||||||
|
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
|
||||||
|
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
|
||||||
|
<el-table-column label="称重时间" width="220" class-name="review-time-cell"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else-if="!busy" description="暂无称重明细" :image-size="48" />
|
||||||
|
</template>
|
||||||
|
</WeightOcrPanel>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<WeightOcrPanel
|
|
||||||
class="density-dialog-section"
|
|
||||||
data-tone="source"
|
|
||||||
review-mode
|
|
||||||
table-mode
|
|
||||||
:correction-mode="correctionMode"
|
|
||||||
:customer-id="customerId"
|
|
||||||
:inbound-id="inboundId"
|
|
||||||
@changed="handleWeightChanged"
|
|
||||||
:class="{ 'review-weight-editor--hidden': !correctionMode }"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" fullscreenable :show-footer="false" :close-on-click-modal="true" destroy-on-close append-to-body @closed="releaseImagePreview">
|
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" fullscreenable :show-footer="false" :close-on-click-modal="true" destroy-on-close append-to-body @closed="releaseImagePreview">
|
||||||
@@ -313,7 +323,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.review-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
|
.review-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
|
||||||
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); position: sticky; top: 0; z-index: 3; flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
||||||
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
|
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
|
||||||
.review-toolbar__status > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
.review-toolbar__status > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||||
.review-toolbar__actions { justify-content: flex-end; }
|
.review-toolbar__actions { justify-content: flex-end; }
|
||||||
@@ -325,6 +335,8 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
.review-section-heading > div { min-width: 0; }
|
.review-section-heading > div { min-width: 0; }
|
||||||
.review-section-heading h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); line-height: 1.35; }
|
.review-section-heading h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); line-height: 1.35; }
|
||||||
.review-section-heading > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); white-space: nowrap; }
|
.review-section-heading > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); white-space: nowrap; }
|
||||||
|
.review-items > .review-section-heading { min-height: var(--admin-control-height, 32px); }
|
||||||
|
.review-weight-items :deep(.weight-list) { margin-top: 0; }
|
||||||
.review-comparison { table-layout: fixed; }
|
.review-comparison { table-layout: fixed; }
|
||||||
.review-extra-facts { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-5, 12px); margin-top: var(--admin-space-3, 8px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
.review-extra-facts { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-5, 12px); margin-top: var(--admin-space-3, 8px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||||
.review-extra-facts strong { color: var(--workbench-text); }
|
.review-extra-facts strong { color: var(--workbench-text); }
|
||||||
@@ -334,7 +346,10 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: var(--admin-radius, 4px); }
|
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: var(--admin-radius, 4px); }
|
||||||
.review-weight-value { color: var(--el-color-success-dark-2); font-weight: 600; }
|
.review-weight-value { color: var(--el-color-success-dark-2); font-weight: 600; }
|
||||||
.review-location { white-space: nowrap; }
|
.review-location { white-space: nowrap; }
|
||||||
.review-weight-editor--hidden { display: none; }
|
.review-detail-heading, .review-weight-summary { display: flex; align-items: center; gap: var(--admin-space-5, 12px); }
|
||||||
|
.review-detail-heading > svg { flex-shrink: 0; }
|
||||||
|
.review-weight-summary { flex-wrap: wrap; color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||||
|
.review-weight-summary strong { color: var(--el-color-success-dark-2); }
|
||||||
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
|
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
|
||||||
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
|
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
|
||||||
.review-image-preview__error { display: grid; justify-items: center; gap: var(--admin-space-3, 8px); color: var(--el-color-danger); }
|
.review-image-preview__error { display: grid; justify-items: center; gap: var(--admin-space-3, 8px); color: var(--el-color-danger); }
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||||
import WeightOcrPanel from './WeightOcrPanel.vue'
|
import WeightOcrPanel from './WeightOcrPanel.vue'
|
||||||
import InboundMeasurementDialog from '../../measurement/InboundMeasurementDialog.vue'
|
import InboundMeasurementDialog from '../../measurement/InboundMeasurementDialog.vue'
|
||||||
|
import MeasurementReviewPanel from '../../review/MeasurementReviewPanel.vue'
|
||||||
|
import { getMeasurementReviewDetail } from '../../review/api'
|
||||||
|
|
||||||
|
vi.mock('../../review/api', () => ({ getMeasurementReviewDetail: vi.fn(), approveMeasurement: vi.fn() }))
|
||||||
|
|
||||||
vi.mock('../api/weightOcr', async () => {
|
vi.mock('../api/weightOcr', async () => {
|
||||||
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
|
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
|
||||||
@@ -139,7 +143,7 @@ function mountPanel(permissions = allWeightPermissions, props: Record<string, un
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWeightDialog(wrapper: VueWrapper) {
|
async function openWeightDialog(wrapper: Pick<VueWrapper, 'get'>) {
|
||||||
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
|
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
}
|
}
|
||||||
@@ -507,6 +511,58 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(wrapper.text()).toContain('1747.500 KG')
|
expect(wrapper.text()).toContain('1747.500 KG')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('corrects a weight in the single review details table and refreshes totals without adding an image section', async () => {
|
||||||
|
vi.mocked(getWeightWorkspace).mockResolvedValue(confirmedReviewWorkspace)
|
||||||
|
const reviewDetail = {
|
||||||
|
inboundId: '20001', inboundCode: 'RK-20001', companyName: '测试客户', channelCode: 'SEA', channelName: '海运',
|
||||||
|
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: null,
|
||||||
|
reviewReason: null, reviewerId: null, reviewerName: null, measurementId: '70001', sourceType: 'DEVICE',
|
||||||
|
measurementStatus: 'SUBMITTED', inboundVersion: 1, version: 1, originalDocumentNo: null,
|
||||||
|
originalGrossWeightKg: 100, originalVolumeCbm: 1, originalPackageCount: 1, actualPackageCount: 1,
|
||||||
|
actualGrossWeightKg: 120, actualNetWeightKg: 108, actualVolumeCbm: 1, volumeWeightKg: 167,
|
||||||
|
actualGoodsName: null, items: [], weightResults: confirmedReviewWorkspace.weightResults,
|
||||||
|
}
|
||||||
|
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(reviewDetail)
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.token = 'token'
|
||||||
|
auth.permissions = [...allWeightPermissions, 'firstleg:inbound:measurement-review']
|
||||||
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
|
const wrapper = mount(MeasurementReviewPanel, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { customerId: '10001', inboundId: '20001', showActions: false },
|
||||||
|
global: { plugins: [pinia, ElementPlus] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.review-weight-table')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('.weight-data-table').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.weight-heading').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('button[aria-label="修改重量 1"]').exists()).toBe(false)
|
||||||
|
await wrapper.get('.review-weight-summary button').trigger('click')
|
||||||
|
await wrapper.get('button[aria-label="修改重量 1"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await dialogWeightInput().setValue('125')
|
||||||
|
await new DOMWrapper(document.body.querySelector<HTMLTextAreaElement>('#weight-correction-reason')!).setValue('复核磅秤读数')
|
||||||
|
const updatedWeight = { ...confirmedReviewWorkspace.weightResults[0], grossWeightKg: 125, netWeightKg: 113, version: 2 }
|
||||||
|
vi.mocked(confirmWeightResult).mockResolvedValue(updatedWeight)
|
||||||
|
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, actualGrossWeightKg: 125, actualNetWeightKg: 113, weightResults: [updatedWeight] })
|
||||||
|
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...reviewDetail, actualGrossWeightKg: 125, actualNetWeightKg: 113, inboundVersion: 2 })
|
||||||
|
await dialogConfirmButton('保存重量修正').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||||
|
version: 1, adjustedWeightValue: 125, adjustedWeightUnit: 'KG', adjustmentReason: '复核磅秤读数',
|
||||||
|
})
|
||||||
|
expect(wrapper.get('.review-weight-summary').text()).toContain('实测毛重 125 kg')
|
||||||
|
expect(wrapper.get('.review-weight-summary').text()).toContain('货物重量 113 kg')
|
||||||
|
expect(wrapper.get('.review-weight-table').text()).toContain('125')
|
||||||
|
expect(wrapper.findAll('.review-weight-table')).toHaveLength(1)
|
||||||
|
const workspaceCalls = vi.mocked(getWeightWorkspace).mock.calls.length
|
||||||
|
await wrapper.get('.review-refresh').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(getWeightWorkspace).toHaveBeenCalledTimes(workspaceCalls + 1)
|
||||||
|
})
|
||||||
|
|
||||||
it('opens the explicit table correction action and atomically confirms an approved weight during measurement review', async () => {
|
it('opens the explicit table correction action and atomically confirms an approved weight during measurement review', async () => {
|
||||||
const confirmed = {
|
const confirmed = {
|
||||||
...pendingWeight,
|
...pendingWeight,
|
||||||
@@ -556,6 +612,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:edit')), status: 'MEASURE_PENDING', correctionMode: true },
|
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:edit')), status: 'MEASURE_PENDING', correctionMode: true },
|
||||||
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:confirm')), status: 'MEASURE_PENDING', correctionMode: true },
|
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:confirm')), status: 'MEASURE_PENDING', correctionMode: true },
|
||||||
{ permissions: allWeightPermissions, status: 'WAIT_SPLIT', correctionMode: true },
|
{ permissions: allWeightPermissions, status: 'WAIT_SPLIT', correctionMode: true },
|
||||||
|
{ permissions: allWeightPermissions, status: 'MEASURE_REJECTED', correctionMode: true },
|
||||||
{ permissions: allWeightPermissions, status: 'MEASURE_PENDING', correctionMode: false },
|
{ permissions: allWeightPermissions, status: 'MEASURE_PENDING', correctionMode: false },
|
||||||
])('keeps table correction unavailable for missing permissions or inactive review: %j', async ({ permissions, status, correctionMode }) => {
|
])('keeps table correction unavailable for missing permissions or inactive review: %j', async ({ permissions, status, correctionMode }) => {
|
||||||
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, processStatus: status })
|
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, processStatus: status })
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ const previewConfirmDisabled = computed(() => {
|
|||||||
|| Boolean(activeAction.value)
|
|| Boolean(activeAction.value)
|
||||||
})
|
})
|
||||||
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
|
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
|
||||||
&& (editable.value || (reviewCorrectionEnabled.value && canEdit.value))))
|
&& ((!props.reviewMode && editable.value) || (reviewCorrectionEnabled.value && canEdit.value))))
|
||||||
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
|
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
|
||||||
&& (editable.value || reviewCorrectionEnabled.value)))
|
&& ((!props.reviewMode && editable.value) || reviewCorrectionEnabled.value)))
|
||||||
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
|
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
|
||||||
? '保存重量修正'
|
? '保存重量修正'
|
||||||
: '确认重量')
|
: '确认重量')
|
||||||
@@ -495,6 +495,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="weight-workspace admin-workbench" aria-labelledby="weight-ocr-title">
|
<section class="weight-workspace admin-workbench" aria-labelledby="weight-ocr-title">
|
||||||
|
<slot name="heading">
|
||||||
<header class="weight-heading">
|
<header class="weight-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
|
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
|
||||||
@@ -535,6 +536,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
><ImagePlus :size="16" />上传称重图片</el-button>
|
><ImagePlus :size="16" />上传称重图片</el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
</slot>
|
||||||
|
|
||||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||||
当前账号缺少入库测量查看权限。
|
当前账号缺少入库测量查看权限。
|
||||||
@@ -565,7 +567,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
<template #details><ApiErrorMeta :details="palletError" /></template>
|
<template #details><ApiErrorMeta :details="palletError" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-else-if="!palletLoading && palletOptions.length === 0 && editable && canEdit"
|
v-else-if="!reviewMode && !palletLoading && palletOptions.length === 0 && editable && canEdit"
|
||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
@@ -575,6 +577,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
<el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
|
<el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<div v-loading="loading" class="weight-list" aria-live="polite">
|
<div v-loading="loading" class="weight-list" aria-live="polite">
|
||||||
|
<slot name="results" :rows="rows" :open-preview="openPreview" :can-correct="reviewCorrectionEnabled && canEdit && canConfirm" :busy="loading || Boolean(activeAction)">
|
||||||
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
||||||
<strong>尚未上传称重图片</strong>
|
<strong>尚未上传称重图片</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -648,6 +651,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| 订舱列表、放舱状态与关联候选 | `pages/OceanBookingsView.vue`;`ocean-bookings` | `api/platformOceanBookings.ts`;`firstleg/oceanbooking/` |
|
| 订舱列表、放舱状态与关联候选 | `pages/OceanBookingsView.vue`;`ocean-bookings` | `api/platformOceanBookings.ts`;`firstleg/oceanbooking/` |
|
||||||
| 新增订舱(登记线下已确认订舱) | `pages/OceanBookingDetailView.vue`;`ocean-bookings/new` | `api/platformOceanBookings.ts` |
|
| 新增订舱(登记线下已确认订舱) | `pages/OceanBookingDetailView.vue`;`ocean-bookings/new` | `api/platformOceanBookings.ts` |
|
||||||
| 订舱详情、资料维护与版本 | `pages/OceanBookingDetailView.vue`;`ocean-bookings/:bookingId` | `api/platformOceanBookings.ts` |
|
| 订舱详情、资料维护与版本 | `pages/OceanBookingDetailView.vue`;`ocean-bookings/:bookingId` | `api/platformOceanBookings.ts` |
|
||||||
| 订舱资料面板与字段边界 | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
|
| 订舱附件组件(详情页已移除入口) | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
|
||||||
| 订舱资料打印 | `components/OceanBookingPrintDialog.vue` | `print/` 单据模板 |
|
| 订舱资料打印 | `components/OceanBookingPrintDialog.vue` | `print/` 单据模板 |
|
||||||
| 柜型需求与数量换算 | `containerRequirements.ts` | `containerCount` 为柜型需求 `quantity` 总和 |
|
| 柜型需求与数量换算 | `containerRequirements.ts` | `containerCount` 为柜型需求 `quantity` 总和 |
|
||||||
| 订舱代理候选 | `bookingAgents.ts` | 公共供应商列表,仅 `ACTIVE` 且类别/范围匹配 |
|
| 订舱代理候选 | `bookingAgents.ts` | 公共供应商列表,仅 `ACTIVE` 且类别/范围匹配 |
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后允许单独维护预计到港及补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
|
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后允许单独维护预计到港及补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
|
||||||
|
|
||||||
|
订舱详情按用户 2026-10-23 截图确认移除“订舱资料”模块及两类文件上传入口,放舱不再要求上传订舱确认文件或提单文件;其他业务资料校验保留。修改后页面待人工截图确认。
|
||||||
|
|
||||||
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
|
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
|
||||||
|
|
||||||
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
|
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ type ViewModel = {
|
|||||||
hasUnsavedActualChanges: boolean
|
hasUnsavedActualChanges: boolean
|
||||||
printDisabledReason: string
|
printDisabledReason: string
|
||||||
save: () => Promise<void>
|
save: () => Promise<void>
|
||||||
canManageDocuments: boolean
|
|
||||||
detail: OceanBookingDetail | null
|
detail: OceanBookingDetail | null
|
||||||
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
|
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
|
||||||
actionSaving: boolean
|
actionSaving: boolean
|
||||||
@@ -346,13 +345,14 @@ describe('OceanBookingDetailView one-click release', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows documents and release for a saved booking with no single customer', async () => {
|
it('allows release without documents for a saved booking with no single customer', async () => {
|
||||||
const empty = booking()
|
const empty = booking()
|
||||||
empty.customerId = null; empty.customerName = null; empty.oceanBooking.customerId = null; empty.oceanBooking.customerName = null
|
empty.customerId = null; empty.customerName = null; empty.oceanBooking.customerId = null; empty.oceanBooking.customerName = null
|
||||||
|
empty.documents = []
|
||||||
vi.mocked(getOceanBooking).mockResolvedValue(empty)
|
vi.mocked(getOceanBooking).mockResolvedValue(empty)
|
||||||
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...empty, status: 'RELEASED', version: 4 })
|
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...empty, status: 'RELEASED', version: 4 })
|
||||||
const { vm, wrapper } = await mountView()
|
const { vm, wrapper } = await mountView()
|
||||||
expect(vm.canManageDocuments).toBe(true)
|
expect(wrapper.text()).not.toContain('订舱资料')
|
||||||
await vm.submitRelease()
|
await vm.submitRelease()
|
||||||
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 })
|
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 })
|
||||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
|||||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)')
|
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)')
|
||||||
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
||||||
expect(source).not.toContain('releaseReason')
|
expect(source).not.toContain('releaseReason')
|
||||||
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
expect(source).not.toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||||
expect(source).toContain('OceanBookingDocumentsPanel')
|
expect(source).not.toContain('OceanBookingDocumentsPanel')
|
||||||
expect(source).toContain('@updated="updateDocuments"')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('freezes the selected carrier standard code into the booking draft', () => {
|
it('freezes the selected carrier standard code into the booking draft', () => {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import type {
|
|||||||
OceanBookingAgentOption,
|
OceanBookingAgentOption,
|
||||||
OceanBookingCarrierCode,
|
OceanBookingCarrierCode,
|
||||||
OceanBookingDraftData,
|
OceanBookingDraftData,
|
||||||
OceanBookingDocument,
|
|
||||||
OceanBookingParty,
|
OceanBookingParty,
|
||||||
OceanBookingSaveRequest,
|
OceanBookingSaveRequest,
|
||||||
OceanBookingStatus,
|
OceanBookingStatus,
|
||||||
@@ -49,7 +48,6 @@ import { containerRequirementError, createBlankContainerRequirement, selectConta
|
|||||||
import { shouldClearBookingAgent } from '../bookingAgents'
|
import { shouldClearBookingAgent } from '../bookingAgents'
|
||||||
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
|
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
|
||||||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||||||
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
|
|
||||||
|
|
||||||
const partyTypes = [
|
const partyTypes = [
|
||||||
{ value: 'SHIPPER', label: '发货人' },
|
{ value: 'SHIPPER', label: '发货人' },
|
||||||
@@ -113,8 +111,6 @@ const formWritable = computed(() => canSave.value)
|
|||||||
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
|
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
|
||||||
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
|
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
|
||||||
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
|
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
|
||||||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
|
|
||||||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
|
|
||||||
const actualSaving = ref(false)
|
const actualSaving = ref(false)
|
||||||
const arrivalDialogOpen = ref(false)
|
const arrivalDialogOpen = ref(false)
|
||||||
const arrivalSaving = ref(false)
|
const arrivalSaving = ref(false)
|
||||||
@@ -419,16 +415,8 @@ function releaseValidationError() {
|
|||||||
}
|
}
|
||||||
if (form.data.estimatedArrivalAt && form.data.estimatedSailingAt && form.data.estimatedArrivalAt < form.data.estimatedSailingAt) return '预计到港时间不能早于预计开船时间'
|
if (form.data.estimatedArrivalAt && form.data.estimatedSailingAt && form.data.estimatedArrivalAt < form.data.estimatedSailingAt) return '预计到港时间不能早于预计开船时间'
|
||||||
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
|
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
|
||||||
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
|
|
||||||
return containerRequirementError(form.data.containerRequirements)
|
return containerRequirementError(form.data.containerRequirements)
|
||||||
}
|
}
|
||||||
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
|
|
||||||
if (!detail.value) return
|
|
||||||
detail.value.documents = documents
|
|
||||||
detail.value.version = bookingVersion
|
|
||||||
detail.value.oceanBooking.version = bookingVersion
|
|
||||||
form.expectedVersion = bookingVersion
|
|
||||||
}
|
|
||||||
async function submitRelease() {
|
async function submitRelease() {
|
||||||
if (!detail.value || workflowBusy.value) return
|
if (!detail.value || workflowBusy.value) return
|
||||||
actionSaving.value = true
|
actionSaving.value = true
|
||||||
@@ -640,7 +628,6 @@ onMounted(() => { void load(); void loadOptions() })
|
|||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
|
||||||
|
|
||||||
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
|
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ describe('truck appointment API', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('uses the server-owned create candidate contract without rebuilding availability', async () => {
|
it('uses the server-owned create candidate contract without rebuilding availability', async () => {
|
||||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true }], page: '1', size: '200', total: '1', pages: '1' } })
|
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运' }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||||
await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({
|
await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({
|
||||||
records: [{ oceanBookingId: '11', warehouseId: '12', bookingDocumentReady: true }],
|
records: [{ oceanBookingId: '11', warehouseId: '12' }],
|
||||||
total: 1,
|
total: 1,
|
||||||
})
|
})
|
||||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ function normalizeCandidate(candidate: TruckAppointmentCandidate & Record<string
|
|||||||
for (const key of ['oceanBookingId', 'warehouseId']) {
|
for (const key of ['oceanBookingId', 'warehouseId']) {
|
||||||
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
||||||
}
|
}
|
||||||
normalized.bookingDocumentReady = normalized.bookingDocumentReady === true
|
|
||||||
return normalized as unknown as TruckAppointmentCandidate
|
return normalized as unknown as TruckAppointmentCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ const model: SaveTruckAppointmentRequest = {
|
|||||||
plannedLoadingAt: null,
|
plannedLoadingAt: null,
|
||||||
}
|
}
|
||||||
const candidates = [
|
const candidates = [
|
||||||
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
|
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运' },
|
||||||
{ oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
|
{ oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运' },
|
||||||
]
|
]
|
||||||
function detail(id: string) {
|
function detail(id: string) {
|
||||||
const candidate = candidates.find((item) => item.oceanBookingId === id)!
|
const candidate = candidates.find((item) => item.oceanBookingId === id)!
|
||||||
@@ -46,6 +46,21 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
|||||||
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
|
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([false, true])('validates a booking without documents when editing is %s', async (editing) => {
|
||||||
|
vi.mocked(getOceanBooking).mockResolvedValue({ ...detail('booking-1'), documents: [] } as never)
|
||||||
|
const wrapper = mount(TruckAppointmentForm, {
|
||||||
|
props: {
|
||||||
|
modelValue: { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', plannedLoadingAt: '2026-10-25T10:00:00' },
|
||||||
|
resource: editing ? resource('appointment-1', 'booking-1') : null,
|
||||||
|
},
|
||||||
|
global: { plugins: [ElementPlus] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
expect(await wrapper.vm.validate()).toBe(true)
|
||||||
|
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('selects only server-approved released bookings and never exposes container identity', async () => {
|
it('selects only server-approved released bookings and never exposes container identity', async () => {
|
||||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('TruckAppointmentForm interaction contract', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps server-side validation authoritative and preserves recoverable option errors', () => {
|
it('keeps server-side validation authoritative and preserves recoverable option errors', () => {
|
||||||
expect(source).toContain('所选订舱缺少有效的订舱确认文件或提单文件')
|
expect(source).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
|
||||||
expect(source).toContain('所选订舱缺少有效提单号')
|
expect(source).toContain('所选订舱缺少有效提单号')
|
||||||
expect(source).toContain("booking.status !== 'RELEASED'")
|
expect(source).toContain("booking.status !== 'RELEASED'")
|
||||||
expect(source).toContain('bookingContainersError')
|
expect(source).toContain('bookingContainersError')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Eye, Save } from '@lucide/vue'
|
|||||||
import { normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
|
import { normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
|
||||||
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
|
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
|
||||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||||
import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings'
|
import { getOceanBooking } from '@/modules/ocean-bookings'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ const relationWarning = ref('')
|
|||||||
const dispatchDetailsOpen = ref(false)
|
const dispatchDetailsOpen = ref(false)
|
||||||
const bookingContainersLoading = ref(false)
|
const bookingContainersLoading = ref(false)
|
||||||
const bookingContainersError = ref<ApiErrorDetails | null>(null)
|
const bookingContainersError = ref<ApiErrorDetails | null>(null)
|
||||||
const bookingDocuments = ref<OceanBookingDocument[]>([])
|
|
||||||
const relationLocked = computed(() => Boolean(props.resource))
|
const relationLocked = computed(() => Boolean(props.resource))
|
||||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00' }
|
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00' }
|
||||||
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
|
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
|
||||||
@@ -39,7 +38,7 @@ const rules: FormRules = {
|
|||||||
if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择可创建拖车单的已放舱订舱'))
|
if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择可创建拖车单的已放舱订舱'))
|
||||||
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
|
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
|
||||||
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
|
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
|
||||||
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
|
return callback()
|
||||||
}, trigger: 'change' }],
|
}, trigger: 'change' }],
|
||||||
carrierId: [{ required: true, message: '请选择拖车供应商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
carrierId: [{ required: true, message: '请选择拖车供应商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
||||||
plannedLoadingAt: [{ required: true, message: '请选择预约装柜时间', trigger: 'change' }],
|
plannedLoadingAt: [{ required: true, message: '请选择预约装柜时间', trigger: 'change' }],
|
||||||
@@ -57,21 +56,17 @@ const selectableCarriers = computed(() => carriers.value.filter((item) => (
|
|||||||
item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC'
|
item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC'
|
||||||
)))
|
)))
|
||||||
const bookings = computed(() => {
|
const bookings = computed(() => {
|
||||||
const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string | null; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
|
const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string | null; warehouseId: string; warehouseName: string | null }>()
|
||||||
for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
|
for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
|
||||||
id: item.oceanBookingId,
|
id: item.oceanBookingId,
|
||||||
bookingNo: item.oceanBookingNo,
|
bookingNo: item.oceanBookingNo,
|
||||||
billOfLadingNo: item.billOfLadingNo,
|
billOfLadingNo: item.billOfLadingNo,
|
||||||
warehouseId: item.warehouseId,
|
warehouseId: item.warehouseId,
|
||||||
warehouseName: item.warehouseName,
|
warehouseName: item.warehouseName,
|
||||||
bookingDocumentReady: item.bookingDocumentReady,
|
|
||||||
})
|
})
|
||||||
return [...result.values()]
|
return [...result.values()]
|
||||||
})
|
})
|
||||||
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId) || null)
|
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId) || null)
|
||||||
const bookingDocumentReady = computed(() => relationLocked.value
|
|
||||||
? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))
|
|
||||||
: Boolean(selectedReleasedBooking.value?.bookingDocumentReady))
|
|
||||||
const selectedBookingFallback = computed(() => {
|
const selectedBookingFallback = computed(() => {
|
||||||
const id = draft.oceanBookingId
|
const id = draft.oceanBookingId
|
||||||
const snapshot = props.resource
|
const snapshot = props.resource
|
||||||
@@ -97,16 +92,13 @@ async function loadCurrentBookingContext(bookingId: string) {
|
|||||||
const booking = await getOceanBooking(bookingId)
|
const booking = await getOceanBooking(bookingId)
|
||||||
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
|
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
|
||||||
if (booking.status !== 'RELEASED') {
|
if (booking.status !== 'RELEASED') {
|
||||||
bookingDocuments.value = []
|
|
||||||
bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' }
|
bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
draft.warehouseId = booking.warehouseId
|
draft.warehouseId = booking.warehouseId
|
||||||
draft.billOfLadingNo = booking.billOfLadingNo
|
draft.billOfLadingNo = booking.billOfLadingNo
|
||||||
bookingDocuments.value = booking.documents || []
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
|
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
|
||||||
bookingDocuments.value = []
|
|
||||||
bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')
|
bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')
|
||||||
} finally {
|
} finally {
|
||||||
if (sequence === bookingLoadSequence) bookingContainersLoading.value = false
|
if (sequence === bookingLoadSequence) bookingContainersLoading.value = false
|
||||||
@@ -116,7 +108,6 @@ async function selectBooking(bookingId: string) {
|
|||||||
bookingLoadSequence += 1
|
bookingLoadSequence += 1
|
||||||
const item = bookings.value.find((entry) => entry.id === bookingId)
|
const item = bookings.value.find((entry) => entry.id === bookingId)
|
||||||
draft.oceanBookingId = item?.id || null
|
draft.oceanBookingId = item?.id || null
|
||||||
bookingDocuments.value = []
|
|
||||||
relationWarning.value = ''
|
relationWarning.value = ''
|
||||||
if (!item) {
|
if (!item) {
|
||||||
draft.warehouseId = null
|
draft.warehouseId = null
|
||||||
@@ -196,7 +187,6 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError" class="appointment-grid__wide readiness-alerts">
|
<div v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError" class="appointment-grid__wide readiness-alerts">
|
||||||
<el-alert v-if="!draft.billOfLadingNo?.trim()" title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
|
<el-alert v-if="!draft.billOfLadingNo?.trim()" title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
|
||||||
<el-alert v-if="!bookingDocumentReady" title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
|
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="预约装柜时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约装柜时间" /></el-form-item>
|
<el-form-item label="预约装柜时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约装柜时间" /></el-form-item>
|
||||||
<el-form-item label="拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" inputmode="decimal" aria-label="拖车费"><template #append>元</template></el-input></el-form-item>
|
<el-form-item label="拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" inputmode="decimal" aria-label="拖车费"><template #append>元</template></el-input></el-form-item>
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ export interface TruckAppointmentCandidate {
|
|||||||
warehouseName: string | null
|
warehouseName: string | null
|
||||||
channelCode: string | null
|
channelCode: string | null
|
||||||
channelName: string | null
|
channelName: string | null
|
||||||
bookingDocumentReady: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TruckAppointmentCandidatePage {
|
export interface TruckAppointmentCandidatePage {
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
|
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
|
||||||
expect(source).toContain('<el-table :data="actualMeasurementRows" row-key="id"')
|
expect(source).toContain('<el-table :data="actualMeasurementRows" row-key="id"')
|
||||||
expect(source).toContain('<el-table-column label="箱号" prop="boxCode"')
|
expect(source).toContain('<el-table-column label="箱号" prop="boxCode"')
|
||||||
expect(source).toContain("row.boxCode || '—'")
|
expect(source).toContain("row.boxCode?.split('#').pop() || '—'")
|
||||||
expect(source).toContain("`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`")
|
expect(source).toContain("`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`")
|
||||||
expect(source).not.toContain('row.boxCode || row.itemNo || row.id')
|
expect(source).not.toContain('row.boxCode || row.itemNo || row.id')
|
||||||
expect(source).toContain('label="尺寸图片"')
|
expect(source).toContain('label="尺寸图片"')
|
||||||
|
|||||||
@@ -2700,10 +2700,21 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
|||||||
<el-table-column v-if="measurementStrategyEditable" label="操作" width="110" fixed="left">
|
<el-table-column v-if="measurementStrategyEditable" label="操作" width="110" fixed="left">
|
||||||
<template #default="{ row }"><el-button type="primary" plain size="small" :loading="measurementOuterSavingId === String(row.id)" :disabled="Boolean(measurementOuterSavingId)" @click="saveWarehouseOuterDimensions(row as Record<string, unknown>)">保存外箱</el-button></template>
|
<template #default="{ row }"><el-button type="primary" plain size="small" :loading="measurementOuterSavingId === String(row.id)" :disabled="Boolean(measurementOuterSavingId)" @click="saveWarehouseOuterDimensions(row as Record<string, unknown>)">保存外箱</el-button></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="箱号" prop="boxCode" min-width="190" fixed="left" show-overflow-tooltip>
|
<el-table-column label="尺寸图片" width="148" fixed="left">
|
||||||
<template #default="{ row }"><strong class="measurement-box-code">{{ row.boxCode || '—' }}</strong></template>
|
<template #default="{ row }">
|
||||||
|
<MeasurementEvidenceThumbnail
|
||||||
|
:customer-id="String(detail.summary.tenantId)"
|
||||||
|
:inbound-id="String(detail.summary.inboundId)"
|
||||||
|
:file-id="row.intelligentImageFileId"
|
||||||
|
:alt="`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`"
|
||||||
|
empty-text="未绑定尺寸图片"
|
||||||
|
@open="openMeasurementEvidence(row.intelligentImageFileId, `尺寸图片 · ${row.boxCode || row.itemNo || '箱规编码待补充'}`)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="箱号" prop="boxCode" width="100" fixed="left" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }"><strong class="measurement-box-code">{{ row.boxCode?.split('#').pop() || '—' }}</strong></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="#" prop="itemNo" min-width="70" fixed="left" />
|
|
||||||
<el-table-column label="仓库修改外箱(长)" width="190" align="center">
|
<el-table-column label="仓库修改外箱(长)" width="190" align="center">
|
||||||
<template #default="{ row }"><el-input-number v-if="measurementStrategyEditable" v-model="row.outerLengthCm" class="measurement-outer-dimension-input" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /><template v-else>{{ formatNumber(row.outerLengthCm) }}</template></template>
|
<template #default="{ row }"><el-input-number v-if="measurementStrategyEditable" v-model="row.outerLengthCm" class="measurement-outer-dimension-input" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /><template v-else>{{ formatNumber(row.outerLengthCm) }}</template></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -2717,18 +2728,6 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
|||||||
<el-table-column v-for="column in actualMeasuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120">
|
<el-table-column v-for="column in actualMeasuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120">
|
||||||
<template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template>
|
<template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="尺寸图片" min-width="148">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<MeasurementEvidenceThumbnail
|
|
||||||
:customer-id="String(detail.summary.tenantId)"
|
|
||||||
:inbound-id="String(detail.summary.inboundId)"
|
|
||||||
:file-id="row.intelligentImageFileId"
|
|
||||||
:alt="`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`"
|
|
||||||
empty-text="未绑定尺寸图片"
|
|
||||||
@open="openMeasurementEvidence(row.intelligentImageFileId, `尺寸图片 · ${row.boxCode || row.itemNo || '箱规编码待补充'}`)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).weightVolume) }}</span></div>
|
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).weightVolume) }}</span></div>
|
||||||
@@ -2748,7 +2747,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="托盘类型" min-width="160">
|
<el-table-column label="托盘类型" width="160">
|
||||||
<template #default="{ row: weightResult }">{{ weightResult.palletType || '未记录类型' }}</template>
|
<template #default="{ row: weightResult }">{{ weightResult.palletType || '未记录类型' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="服务端确认毛重(kg)" min-width="180" align="right">
|
<el-table-column label="服务端确认毛重(kg)" min-width="180" align="right">
|
||||||
|
|||||||
Reference in New Issue
Block a user