Release/test 合并上生产 #2
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getPlatformOrderFulfillmentTree, normalizeOrderFulfillmentTree } from './orderFulfillment'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('single-remainder order fulfillment contract', () => {
|
||||
beforeEach(() => vi.mocked(http.get).mockReset())
|
||||
|
||||
it('normalizes the new source-fragment plus one remainder model without double counting', () => {
|
||||
const tree = normalizeOrderFulfillmentTree({
|
||||
rootOrderId: '10', rootOrderNo: 'CC-10', rootVersion: 3,
|
||||
originalPackageCount: 4, cumulativeLoadedPackageCount: 2, currentRemainderPackageCount: 2,
|
||||
fragments: [
|
||||
{ orderId: '10', orderNo: 'CC-10', splitSequence: 0, splitRole: 'COMPLETED_FRAGMENT', plannedPackageCount: 4, pickedPackageCount: 1, unpickedPackageCount: 0 },
|
||||
{ orderId: '11', orderNo: 'CC-10-1', splitSequence: 1, splitRole: 'COMPLETED_FRAGMENT', plannedPackageCount: 3, pickedPackageCount: 1, unpickedPackageCount: 0 },
|
||||
{ orderId: '12', orderNo: 'CC-10-2', splitSequence: 2, splitRole: 'ACTIVE_REMAINDER', plannedPackageCount: 2, pickedPackageCount: 0, unpickedPackageCount: 2 },
|
||||
],
|
||||
}, '10')
|
||||
expect(tree).toMatchObject({
|
||||
rootOrderId: '10', originalPackageCount: 4,
|
||||
cumulativeLoadedPackageCount: 2, currentRemainderPackageCount: 2,
|
||||
historicalTwoChildModel: false,
|
||||
})
|
||||
expect(tree.fragments.at(-1)).toMatchObject({ orderNo: 'CC-10-2', pickedPackageCount: 0, unpickedPackageCount: 2 })
|
||||
})
|
||||
|
||||
it('keeps historical LOADED plus REMAINDER trees readable and marks them as historical', () => {
|
||||
const tree = normalizeOrderFulfillmentTree({
|
||||
parentOrderId: 10, parentOrderNo: 'CC-10', parentVersion: 2,
|
||||
allocatedPackageCount: 1, remainingPackageCount: 3,
|
||||
children: [
|
||||
{ orderId: 11, orderNo: 'CC-10-1', splitSequence: 1, splitRole: 'LOADED', packageCount: 1, sourceOrderId: 10 },
|
||||
{ orderId: 12, orderNo: 'CC-10-2', splitSequence: 2, splitRole: 'REMAINDER', packageCount: 3, sourceOrderId: 10 },
|
||||
],
|
||||
}, '10')
|
||||
expect(tree.historicalTwoChildModel).toBe(true)
|
||||
expect(tree.originalPackageCount).toBe(4)
|
||||
expect(tree.fragments.map((item) => [item.pickedPackageCount, item.unpickedPackageCount])).toEqual([[1, 0], [0, 3]])
|
||||
})
|
||||
|
||||
it('queries the real platform fulfillment-tree with tenant isolation', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { rootOrderId: '10', fragments: [] } })
|
||||
await expect(getPlatformOrderFulfillmentTree('20', '10')).resolves.toMatchObject({ rootOrderId: '10' })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/10/fulfillment-tree', { params: { tenantId: '20' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface UnifiedOrderFulfillmentFragment {
|
||||
orderId: string
|
||||
orderNo: string | null
|
||||
splitSequence: number | null
|
||||
splitRole: string | null
|
||||
plannedPackageCount: number | null
|
||||
pickedPackageCount: number | null
|
||||
unpickedPackageCount: number | null
|
||||
loadingPlanId: string | null
|
||||
containerBookingId: string | null
|
||||
sourceOrderId: string | null
|
||||
status: string | null
|
||||
historical: boolean
|
||||
}
|
||||
|
||||
export interface UnifiedOrderFulfillmentTree {
|
||||
rootOrderId: string
|
||||
rootOrderNo: string | null
|
||||
rootVersion: number | null
|
||||
originalPackageCount: number | null
|
||||
cumulativeLoadedPackageCount: number | null
|
||||
currentRemainderPackageCount: number | null
|
||||
fragments: UnifiedOrderFulfillmentFragment[]
|
||||
historicalTwoChildModel: boolean
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
function record(value: unknown): UnknownRecord {
|
||||
return value && typeof value === 'object' ? value as UnknownRecord : {}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return value === null || value === undefined || value === '' ? null : String(value)
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : null
|
||||
}
|
||||
|
||||
function firstNumber(source: UnknownRecord, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = numberValue(source[key])
|
||||
if (value !== null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function firstString(source: UnknownRecord, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = stringValue(source[key])
|
||||
if (value !== null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeOrderFulfillmentTree(payload: unknown, fallbackOrderId: string): UnifiedOrderFulfillmentTree {
|
||||
const raw = record(payload)
|
||||
const rootOrderId = firstString(raw, ['rootOrderId', 'parentOrderId', 'orderId']) || fallbackOrderId
|
||||
const rootOrderNo = firstString(raw, ['rootOrderNo', 'parentOrderNo', 'orderNo'])
|
||||
const children = Array.isArray(raw.fragments) ? raw.fragments : Array.isArray(raw.children) ? raw.children : []
|
||||
const legacy = raw.historicalTwoChildModel === true
|
||||
|| children.some((item) => {
|
||||
const child = record(item)
|
||||
const role = firstString(child, ['splitRole', 'role'])
|
||||
const childId = firstString(child, ['orderId', 'childOrderId'])
|
||||
const sourceId = firstString(child, ['sourceOrderId', 'parentOrderId'])
|
||||
return role === 'LOADED' && childId !== null && sourceId !== null && childId !== sourceId
|
||||
})
|
||||
const original = firstNumber(raw, ['originalPackageCount', 'sourcePackageCount'])
|
||||
?? (() => {
|
||||
const allocated = firstNumber(raw, ['cumulativeLoadedPackageCount', 'allocatedPackageCount', 'loadedPackageCount'])
|
||||
const remainder = firstNumber(raw, ['currentRemainderPackageCount', 'remainingPackageCount'])
|
||||
return allocated === null || remainder === null ? null : allocated + remainder
|
||||
})()
|
||||
const cumulativeLoaded = firstNumber(raw, ['cumulativeLoadedPackageCount', 'allocatedPackageCount', 'loadedPackageCount'])
|
||||
const currentRemainder = firstNumber(raw, ['currentRemainderPackageCount', 'remainingPackageCount'])
|
||||
const fragments = children.map((item): UnifiedOrderFulfillmentFragment => {
|
||||
const child = record(item)
|
||||
const role = firstString(child, ['splitRole', 'fragmentRole', 'role'])
|
||||
const planned = firstNumber(child, ['plannedPackageCount', 'sourcePackageCount', 'packageCount'])
|
||||
const picked = firstNumber(child, ['pickedPackageCount', 'loadedPackageCount'])
|
||||
?? (role === 'LOADED' ? firstNumber(child, ['packageCount']) : 0)
|
||||
const unpicked = firstNumber(child, ['unpickedPackageCount', 'remainingPackageCount'])
|
||||
?? (planned === null || picked === null ? null : Math.max(0, planned - picked))
|
||||
return {
|
||||
orderId: firstString(child, ['orderId', 'childOrderId', 'sourceOrderId']) || '',
|
||||
orderNo: firstString(child, ['orderNo', 'childOrderNo', 'sourceOrderNo']),
|
||||
splitSequence: firstNumber(child, ['splitSequence', 'sequence']),
|
||||
splitRole: role,
|
||||
plannedPackageCount: planned,
|
||||
pickedPackageCount: picked,
|
||||
unpickedPackageCount: unpicked,
|
||||
loadingPlanId: firstString(child, ['loadingPlanId']),
|
||||
containerBookingId: firstString(child, ['containerBookingId']),
|
||||
sourceOrderId: firstString(child, ['sourceOrderId', 'parentOrderId']),
|
||||
status: firstString(child, ['status', 'orderStatus', 'processStatus']),
|
||||
historical: legacy,
|
||||
}
|
||||
}).filter((item) => item.orderId)
|
||||
return {
|
||||
rootOrderId,
|
||||
rootOrderNo,
|
||||
rootVersion: firstNumber(raw, ['rootVersion', 'parentVersion']),
|
||||
originalPackageCount: original,
|
||||
cumulativeLoadedPackageCount: cumulativeLoaded,
|
||||
currentRemainderPackageCount: currentRemainder,
|
||||
fragments,
|
||||
historicalTwoChildModel: legacy,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformOrderFulfillmentTree(tenantId: string, orderId: string) {
|
||||
const response = await http.get<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return normalizeOrderFulfillmentTree(unwrapApiResponse(response), orderId)
|
||||
}
|
||||
@@ -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/unified-orders/components/OrderFulfillmentTreePanel.vue'), 'utf8')
|
||||
|
||||
describe('OrderFulfillmentTreePanel contract', () => {
|
||||
it('shows root conservation fields and fragment picking quantities', () => {
|
||||
for (const label of ['原始总量', '累计已装', '当前余量', '本单计划', '已拣', '未拣']) {
|
||||
expect(source).toContain(label)
|
||||
}
|
||||
expect(source).toContain('历史双子单模型')
|
||||
expect(source).toContain('新拆分不会再生成已装子单')
|
||||
})
|
||||
|
||||
it('covers loading, error, empty and responsive states', () => {
|
||||
expect(source).toContain('正在加载拆分履约')
|
||||
expect(source).toContain('拆分履约暂时无法加载')
|
||||
expect(source).toContain('<ApiErrorMeta')
|
||||
expect(source).toContain('暂无拆分分片')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
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 { getPlatformOrderFulfillmentTree, type UnifiedOrderFulfillmentTree } from '../api/orderFulfillment'
|
||||
|
||||
const props = defineProps<{ tenantId: string; orderId: string; compact?: boolean }>()
|
||||
const tree = ref<UnifiedOrderFulfillmentTree | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try { tree.value = await getPlatformOrderFulfillmentTree(props.tenantId, props.orderId) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function roleLabel(role: string | null) {
|
||||
if (role === 'REMAINDER' || role === 'ACTIVE_REMAINDER') return '当前余量'
|
||||
if (role === 'LOADED' || role === 'COMPLETED_FRAGMENT' || role === 'SOURCE_COMPLETED') return '已完成分片'
|
||||
return role || '分片'
|
||||
}
|
||||
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fulfillment-tree-panel" :class="{ compact }" aria-label="拆分履约聚合">
|
||||
<LoadErrorState v-if="error" title="拆分履约暂时无法加载" :message="error.message" :loading="loading" @retry="load">
|
||||
<ApiErrorMeta :details="error" />
|
||||
</LoadErrorState>
|
||||
<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>{{ 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" 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="类型" width="120"><template #default="{ row }">{{ roleLabel(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>
|
||||
<el-table-column label="未拣" width="90"><template #default="{ row }">{{ row.unpickedPackageCount ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="130"><template #default="{ row }"><StatusBadge :status="row.status || roleLabel(row.splitRole)" /></template></el-table-column>
|
||||
<el-table-column label="柜次" min-width="150"><template #default="{ row }">{{ row.containerBookingId || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-empty v-else description="暂无拆分履约数据" :image-size="64" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fulfillment-tree-panel { display: grid; gap: 16px; }
|
||||
.tree-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; padding: 14px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface-muted); }
|
||||
.tree-summary div { display: grid; gap: 4px; min-width: 0; }
|
||||
.tree-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
.tree-summary strong { overflow: hidden; color: var(--cc-text); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-loading { min-height: 120px; display: grid; place-items: center; color: var(--cc-muted); }
|
||||
small { color: var(--cc-muted); font-weight: 400; }
|
||||
@media (max-width: 720px) { .tree-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
@@ -150,6 +150,14 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('await loadOrders()')
|
||||
})
|
||||
|
||||
it('exposes the single-remainder fulfillment aggregate in row expansion and detail', () => {
|
||||
expect(source).toContain("{ key: 'fulfillment', type: 'expand', width: 52, fixed: 'left'")
|
||||
expect(source).toContain('<template #fulfillment="{ row }">')
|
||||
expect(source).toContain("{ key: 'fulfillment', label: '拆分履约', icon: ListChecks }")
|
||||
expect(source).toContain("activeDetailTab === 'fulfillment'")
|
||||
expect(source).toContain('根单、已完成分片与当前余量')
|
||||
})
|
||||
|
||||
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]')
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type UnifiedOrderResource,
|
||||
type UnifiedOrderFeeDetailResource,
|
||||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -106,6 +107,7 @@ const detailTabs = [
|
||||
{ key: 'history', label: '状态历史', icon: History },
|
||||
{ key: 'measurement', label: '测量数据', icon: Ruler },
|
||||
{ key: 'other', label: '其他', icon: StickyNote },
|
||||
{ key: 'fulfillment', label: '拆分履约', icon: ListChecks },
|
||||
]
|
||||
|
||||
const detailTabOptions = computed(() => detail.value?.summary.inboundId
|
||||
@@ -181,6 +183,7 @@ const queryFields = computed(() => showAdvancedFilters.value
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 52, fixed: 'left', align: 'center', selectable: canBulkReview },
|
||||
{ key: 'fulfillment', type: 'expand', width: 52, fixed: 'left', slotName: 'fulfillment', hideable: false, movable: false },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
|
||||
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' },
|
||||
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'measurementReviewStatus' },
|
||||
@@ -960,6 +963,11 @@ onMounted(() => {
|
||||
<ol v-else class="history-list" aria-label="状态历史"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><span class="history-marker" aria-hidden="true" /><div><div class="history-line"><strong>{{ historyActionLabel(item.action || item.eventType) }}</strong><StatusBadge :status="statusLabel(item.mainStatus)" /></div><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark" class="history-remark">{{ item.remark }}</p></div></li></ol>
|
||||
</template>
|
||||
|
||||
<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)" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeDetailTab === 'workflow'">
|
||||
<UnifiedOrderWorkflowPanel
|
||||
:tenant-id="String(detail.summary.tenantId)"
|
||||
@@ -1064,6 +1072,13 @@ onMounted(() => {
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #fulfillment="{ row }">
|
||||
<OrderFulfillmentTreePanel
|
||||
:tenant-id="String((row as UnifiedOrderResource).tenantId)"
|
||||
:order-id="String((row as UnifiedOrderResource).orderId)"
|
||||
compact
|
||||
/>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user