This commit is contained in:
2026-08-14 19:59:32 +08:00
parent f7594ebd39
commit 1540b80c89
29 changed files with 780 additions and 587 deletions
@@ -11,6 +11,11 @@ describe('OrderFulfillmentTreePanel contract', () => {
}
expect(source).toContain('历史双子单模型')
expect(source).toContain('新拆分不会再生成已装子单')
expect(source).toContain('inboundCode?: string | null')
expect(source).toContain("props.inboundCode || '暂缺'")
expect(source).not.toContain('tree.rootOrderNo')
expect(source).not.toContain('tree.rootOrderId')
expect(source).not.toContain('row.orderNo')
})
it('covers loading, error, empty and responsive states', () => {
@@ -7,7 +7,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformOrderFulfillmentTree, type UnifiedOrderFulfillmentTree } from '../api/orderFulfillment'
import { unifiedOrderFulfillmentRoleLabel, unifiedOrderFulfillmentStatusLabel } from '../status'
const props = defineProps<{ tenantId: string; orderId: string; compact?: boolean }>()
const props = defineProps<{ tenantId: string; orderId: string; inboundCode?: string | null; compact?: boolean }>()
const tree = ref<UnifiedOrderFulfillmentTree | null>(null)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
@@ -31,14 +31,14 @@ onMounted(() => { void load() })
<div v-else-if="loading" class="tree-loading" aria-live="polite">正在加载拆分履约…</div>
<template v-else-if="tree">
<div class="tree-summary">
<div><span>根单</span><strong>{{ tree.rootOrderNo || tree.rootOrderId }}</strong></div>
<div><span>入仓码</span><strong>{{ props.inboundCode || '暂缺' }}</strong></div>
<div><span>原始总量</span><strong>{{ tree.originalPackageCount ?? '—' }} 箱</strong></div>
<div><span>累计已装</span><strong>{{ tree.cumulativeLoadedPackageCount ?? '—' }} 箱</strong></div>
<div><span>当前余量</span><strong>{{ tree.currentRemainderPackageCount ?? '—' }} 箱</strong></div>
</div>
<el-alert v-if="tree.historicalTwoChildModel" type="info" :closable="false" show-icon title="该记录使用历史双子单模型,仅兼容展示;新拆分不会再生成已装子单。" />
<el-table :data="tree.fragments" row-key="orderId" border stripe empty-text="暂无拆分分片">
<el-table-column label="分片订单" min-width="180"><template #default="{ row }"><strong>{{ row.orderNo || row.orderId }}</strong><small v-if="row.splitSequence != null"> · 第 {{ row.splitSequence }} 次</small></template></el-table-column>
<el-table-column label="分片" min-width="180"><template #default="{ row }"><strong>{{ unifiedOrderFulfillmentRoleLabel(row.splitRole) }}</strong><small v-if="row.splitSequence != null"> · 第 {{ row.splitSequence }} 次</small></template></el-table-column>
<el-table-column label="类型" width="120"><template #default="{ row }">{{ unifiedOrderFulfillmentRoleLabel(row.splitRole) }}</template></el-table-column>
<el-table-column label="本单计划" width="100"><template #default="{ row }">{{ row.plannedPackageCount ?? '—' }} 箱</template></el-table-column>
<el-table-column label="已拣" width="90"><template #default="{ row }">{{ row.pickedPackageCount ?? '—' }}</template></el-table-column>
@@ -93,7 +93,7 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('v-model:page-size="pageSize"')
const columnSource = source.slice(source.indexOf('const columns:'), source.indexOf('function emptyFilters'))
const expectedColumnKeys = [
'actions', 'orderNo', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
'destinationWarehouseName', 'totalPackageCount', 'measurementReviewStatus', 'actualGrossWeightKg',
'actualVolumeCbm', 'chargeableWeightKg', 'expectedShipDate', 'loadingStatus', 'containerNo', 'cabinetNo',
'sailingAt', 'portArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt',
@@ -103,8 +103,8 @@ describe('UnifiedOrdersView contract', () => {
'internalOrderStatus', 'expectedArrivalAt', 'feeReviewStatus', 'holdCargo', 'companyName',
'pickupMethod', 'chargeableVolumeCbm', 'recommendedPalletType', 'locationCodes', 'associatedFiles', 'createdBy',
]) expect(columnSource).not.toContain(`key: '${uncontractedKey}'`)
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'orderNo'"))
expect(columnSource.indexOf("key: 'orderNo'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
expect(columnSource).not.toContain("key: 'orderNo'")
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'")
expect(columnSource).toContain("{ key: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field', defaultVisible: false }")
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(8)
@@ -229,8 +229,8 @@ describe('UnifiedOrdersView contract', () => {
it('distinguishes overflow action buttons by row and keeps a stable missing-field fallback', () => {
expect(source).toContain('function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource)')
expect(source).toContain('[compatibleOrder.inboundCode, compatibleOrder.orderNo, compatibleOrder.orderId, compatibleOrder.id]')
expect(source).toContain("identifier || '当前订单'")
expect(source).toContain("String(row.inboundCode || '').trim() || '当前入仓单'")
expect(source).not.toContain('compatibleOrder.orderNo')
expect(source).not.toContain('aria-label="更多订单操作"')
expect(source).not.toContain('title="更多操作"')
})
@@ -210,7 +210,6 @@ const queryFields = computed(() => showAdvancedFilters.value
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 190, slotName: 'order' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
@@ -676,11 +675,7 @@ function moreUnifiedOrderActions(row: UnifiedOrderResource) {
return unifiedOrderSecondaryActions(row).slice(2)
}
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
const compatibleOrder = row as UnifiedOrderResource & { id?: string | number | null }
const identifier = [compatibleOrder.inboundCode, compatibleOrder.orderNo, compatibleOrder.orderId, compatibleOrder.id]
.map((value) => String(value ?? '').trim())
.find(Boolean)
return `更多订单操作:${identifier || '当前订单'}`
return `更多订单操作:${String(row.inboundCode || '').trim() || '当前入仓单'}`
}
function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
if (action === 'note') return '添加备注'
@@ -763,8 +758,7 @@ onMounted(() => {
<el-button link class="back-button" @click="closeDetail"><ArrowLeft :size="16" />返回列表</el-button>
<span class="order-mark" aria-hidden="true"><Box :size="19" /></span>
<div class="order-title">
<h1>{{ detail?.summary.inboundCode || (detail?.summary.inboundId ? `入库单 ${detail.summary.inboundId}` : '暂未生成') }}</h1>
<p>{{ detail?.summary.orderNo || `订单 ${orderId}` }}</p>
<h1>{{ detail?.summary.inboundCode || '入仓码暂缺' }}</h1>
</div>
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
</div>
@@ -792,7 +786,7 @@ onMounted(() => {
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
<template v-if="activeDetailTab === 'basic'">
<div class="panel-title"><div><h2>订单基本信息</h2><p>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</p></div><Box :size="19" /></div>
<div class="panel-title"><div><h2>订单基本信息</h2><p>{{ detail.summary.inboundCode || '入仓码暂缺' }}</p></div><Box :size="19" /></div>
<dl class="order-info-grid">
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ firstLegTransportModeLabel(detail.order.order?.transportMode) }}</dd></div>
@@ -898,7 +892,7 @@ onMounted(() => {
<template v-else-if="activeDetailTab === 'fulfillment'">
<div class="panel-title"><div><h2>拆分履约</h2><p>根单、已完成分片与当前余量</p></div><ListChecks :size="19" /></div>
<OrderFulfillmentTreePanel :tenant-id="String(detail.summary.tenantId)" :order-id="String(detail.summary.orderId)" />
<OrderFulfillmentTreePanel :tenant-id="String(detail.summary.tenantId)" :order-id="String(detail.summary.orderId)" :inbound-code="detail.summary.inboundCode" />
</template>
<template v-else-if="activeDetailTab === 'workflow'">
@@ -1021,7 +1015,6 @@ onMounted(() => {
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
<template #field="{ row, column }"><LineClampText v-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
@@ -1043,6 +1036,7 @@ onMounted(() => {
v-if="relationOrder"
:tenant-id="String(relationOrder.tenantId)"
:order-id="String(relationOrder.orderId)"
:inbound-code="relationOrder.inboundCode"
/>
</AppDrawer>
@@ -1056,7 +1050,7 @@ onMounted(() => {
<el-dialog
v-model="measurementDialogOpen"
:title="measurementOrder ? `测量 · ${measurementOrder.inboundCode || measurementOrder.orderNo}` : '测量'"
:title="measurementOrder?.inboundCode ? `测量 · ${measurementOrder.inboundCode}` : '测量'"
width="min(1180px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
@@ -65,7 +65,7 @@ const volumeRate = computed(() => utilization(usedVolume.value, container.value?
const weightRate = computed(() => utilization(usedWeight.value, container.value?.maxWeightKg))
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '候选货物', placeholder: '订单号、入仓码、客户或品名', props: { 'aria-label': '候选货物关键词' } },
{ key: 'keyword', type: 'input', label: '候选货物', placeholder: '入仓码、客户或品名', props: { 'aria-label': '候选货物关键词' } },
{
key: 'eligibility', type: 'select', label: '可装状态', placeholder: '全部状态',
options: [{ label: '可加入', value: 'ELIGIBLE' }, { label: '存在冲突', value: 'CONFLICT' }],
@@ -138,7 +138,7 @@ const filteredCandidates = computed(() => {
const keyword = String(appliedFilters.value.keyword || '').trim().toLowerCase()
const eligibility = String(appliedFilters.value.eligibility || '')
return candidates.value.filter((candidate) => {
const searchable = [candidate.orderNo, candidate.inboundCode, candidate.customerName, candidate.goodsName].join(' ').toLowerCase()
const searchable = [candidate.inboundCode, candidate.customerName, candidate.goodsName].join(' ').toLowerCase()
if (keyword && !searchable.includes(keyword)) return false
const eligible = fittingCount(candidate) > 0
return !eligibility || (eligibility === 'ELIGIBLE' ? eligible : !eligible)
@@ -309,7 +309,7 @@ onMounted(() => { void load() })
<el-button size="small" plain type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)"><Pencil :size="14" />调整</el-button>
<el-button size="small" plain type="danger" :disabled="!canDelete || !editable || Boolean(action)" @click="openRemove(row as SmartLoadingPlanItem)"><Trash2 :size="13" />移除</el-button>
</template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</small></template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</strong></template>
<template #weight="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).grossWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).volumeCbm) }}</template>
<template #destination="{ row }">{{ (row as SmartLoadingPlanItem).destinationWarehouseName || (row as SmartLoadingPlanItem).destinationWarehouseCode || '—' }}</template>
@@ -324,7 +324,7 @@ onMounted(() => { void load() })
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
<template #actions="{ row }"><el-button size="small" plain type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)"><Plus :size="14" />加入</el-button></template>
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</small></template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</strong></template>
<template #destination="{ row }">{{ (row as SmartLoadingCandidate).destinationWarehouseName || (row as SmartLoadingCandidate).destinationWarehouseCode || '—' }}</template>
<template #unitVolume="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitVolumeCbm) }}</template>
<template #unitWeight="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitWeightKg) }}</template>
@@ -336,19 +336,19 @@ onMounted(() => { void load() })
</template>
<AppDialog v-model="assignOpen" title="加入装柜方案" :confirm-loading="action === 'assign'" :confirm-disabled="packageCount < 1 || packageCount > (selectedCandidate ? fittingCount(selectedCandidate) : 0)" confirm-text="确认加入" @confirm="confirmAssign">
<p>{{ selectedCandidate?.orderNo || selectedCandidate?.inboundCode }} · 最多可加入 {{ selectedCandidate ? fittingCount(selectedCandidate) : 0 }} 箱</p>
<p>{{ selectedCandidate?.inboundCode || '入仓码暂缺' }} · 最多可加入 {{ selectedCandidate ? fittingCount(selectedCandidate) : 0 }} 箱</p>
<el-input-number v-model="packageCount" :min="1" :max="selectedCandidate ? fittingCount(selectedCandidate) : 1" controls-position="right" aria-label="加入箱数" />
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="adjustOpen" title="调整装柜箱数" :confirm-loading="action === 'adjust'" :confirm-disabled="packageCount < 1" confirm-text="保存调整" @confirm="confirmAdjust">
<p>{{ selectedItem?.orderNo || selectedItem?.inboundCode }} · 当前 {{ selectedItem?.packageCount || 0 }} 箱</p>
<p>{{ selectedItem?.inboundCode || '入仓码暂缺' }} · 当前 {{ selectedItem?.packageCount || 0 }} 箱</p>
<el-input-number v-model="packageCount" :min="1" controls-position="right" aria-label="调整后箱数" />
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="removeOpen" title="移除装柜明细" confirm-type="danger" confirm-text="确认移除" :confirm-loading="action === 'remove'" @confirm="confirmRemove">
<p>将从当前草稿移除 {{ selectedItem?.orderNo || selectedItem?.inboundCode }} 的 {{ selectedItem?.packageCount || 0 }} 箱货物。此操作会写入审计历史。</p>
<p>将从当前草稿移除 {{ selectedItem?.inboundCode || '该入仓单' }} 的 {{ selectedItem?.packageCount || 0 }} 箱货物。此操作会写入审计历史。</p>
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
@@ -548,7 +548,7 @@ onMounted(() => { void refresh() })
<el-input v-model="preloadDraft.containerCode" maxlength="64" placeholder="请输入柜号" @input="preloadDraftError = ''" />
</el-form-item>
<el-form-item label="货物引用" required :error="preloadDraftError === '货物引用不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="订单号、入仓码或业务引用" @input="preloadDraftError = ''" />
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="入仓码或业务引用" @input="preloadDraftError = ''" />
</el-form-item>
<div class="preload-dialog-grid">
<el-form-item label="箱数" required><el-input-number v-model="preloadDraft.packageCount" :min="1" :step="1" step-strictly controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
@@ -19,7 +19,7 @@ const summary = computed(() => detail.value?.summary || null)
async function load() {
if (!orderId.value || !tenantId.value) {
error.value = { message: '订单作业上下文缺少订单号或客户标识' }
error.value = { message: '订单作业上下文不完整' }
return
}
loading.value = true