This commit is contained in:
2026-08-30 17:43:03 +08:00
parent de1b956d43
commit ae52141952
45 changed files with 404 additions and 82 deletions
@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from './http'
import { overseasHttp } from './overseasHttp'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation } from './inboundCodeRelations'
vi.mock('./http', async (original) => ({ ...(await original<typeof import('./http')>()), http: { get: vi.fn() } }))
vi.mock('./overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('inbound-code relationship APIs', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the frozen domestic and overseas read models and encodes the business code', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', orderNo: 'ORD-1' } })
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', found: false, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [] } })
await getPlatformInboundCodeAssociation('IN/01')
await getLoadingAssociationSummary('IN/01')
await getOverseasInboundCodeRelationship('IN/01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/inbound-code-associations/IN%2F01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-associations/summary', { params: { inboundCode: 'IN/01' } })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/inbound-code-relationships/IN%2F01')
})
it('does not accept customer or internal ids as lookup parameters', () => {
const source = `${getPlatformInboundCodeAssociation}${getLoadingAssociationSummary}${getOverseasInboundCodeRelationship}`
expect(source).not.toMatch(/customerId|inboundId|orderId|warehouseId/)
})
})
+67
View File
@@ -0,0 +1,67 @@
import { http, unwrapApiResponse, type ApiEnvelope } from './http'
import { overseasHttp } from './overseasHttp'
export interface PlatformInboundCodeAssociation {
inboundCode: string
orderNo: string
orderStatus?: string | null
orderId: string
customerId: string
customerName: string
destinationType?: string | null
destinationCountryCode?: string | null
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
}
export interface OverseasInboundCodeRelationship {
inboundCode: string
found: boolean
customer: { customerId: string; customerName: string } | null
overseasWarehouse: { warehouseId: string; warehouseCode: string; warehouseName: string } | null
overseasReceivingStatus?: string | null
taillegOrders: Array<{ orderNo: string; orderStatus?: string | null }>
inboundPlans: Array<{ planNo: string; planStatus?: string | null; receivingStatus?: string | null }>
palletCodes: string[]
inventory: Array<{ skuCode: string; productNameZh?: string | null; productNameEn?: string | null; packageCount: number }>
}
export interface LoadingAssociationSummary {
inboundId: string
inboundCode: string
orderNo?: string | null
loadingPlanId?: string | null
loadingPlanNo?: string | null
containerBookingId?: string | null
cabinetNo?: string | null
containerNo?: string | null
loadingStatus?: string | null
taskId?: string | null
pdaTaskStatus?: string | null
coLoadGroupId?: string | null
coLoadGroupNo?: string | null
items: Array<{ itemId: string }>
}
export async function getPlatformInboundCodeAssociation(inboundCode: string) {
const response = await http.get<ApiEnvelope<PlatformInboundCodeAssociation>, ApiEnvelope<PlatformInboundCodeAssociation>>(
`/platform/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode)}`,
)
return unwrapApiResponse(response)
}
export async function getLoadingAssociationSummary(inboundCode: string) {
const response = await http.get<ApiEnvelope<LoadingAssociationSummary>, ApiEnvelope<LoadingAssociationSummary>>(
'/platform/v1/first-leg/loading-associations/summary',
{ params: { inboundCode } },
)
return unwrapApiResponse(response)
}
export async function getOverseasInboundCodeRelationship(inboundCode: string) {
const response = await overseasHttp.get<ApiEnvelope<OverseasInboundCodeRelationship>, ApiEnvelope<OverseasInboundCodeRelationship>>(
`/platform/v1/last-leg/inventory/inbound-code-relationships/${encodeURIComponent(inboundCode)}`,
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/InboundCodeRelationshipDrawer.vue'), 'utf8')
describe('InboundCodeRelationshipDrawer contract', () => {
it('loads only permission-authorized server read models and keeps failures isolated', () => {
expect(source).toContain('props.canReadBase')
expect(source).toContain('props.canReadLoading')
expect(source).toContain('props.canReadOverseas')
expect(source).toContain('getPlatformInboundCodeAssociation(code)')
expect(source).toContain('getLoadingAssociationSummary(code)')
expect(source).toContain('getOverseasInboundCodeRelationship(code)')
expect(source).toContain('await Promise.all(tasks)')
expect(source).toContain('<ApiErrorMeta :details="baseError"')
expect(source).toContain('<ApiErrorMeta :details="overseasError"')
expect(source).toContain('服务端未找到该入仓码的海外关联数据')
expect(source).not.toMatch(/listPlatform|customerId\)|warehouseId\)/)
})
it('shows business numbers and names without rendering internal ids', () => {
expect(source).toContain('baseAssociation.inboundCode')
expect(source).toContain('baseAssociation.orderNo')
expect(source).toContain('baseAssociation.customerName')
expect(source).toContain('baseAssociation.destinationWarehouseName')
expect(source).not.toContain('{{ baseAssociation.orderId')
expect(source).not.toContain('{{ baseAssociation.customerId')
expect(source).not.toContain('{{ baseAssociation.destinationWarehouseId')
expect(source).toContain('text-overflow:ellipsis')
expect(source).toContain(':title="display(baseAssociation.inboundCode)"')
})
})
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation, type LoadingAssociationSummary, type OverseasInboundCodeRelationship, type PlatformInboundCodeAssociation } from '@/shared/api/inboundCodeRelations'
import ApiErrorMeta from './ApiErrorMeta.vue'
import AppDrawer from './AppDrawer.vue'
import LoadErrorState from './LoadErrorState.vue'
import StatusBadge from './StatusBadge.vue'
const props = defineProps<{ modelValue: boolean; inboundCode: string; canReadBase: boolean; canReadLoading: boolean; canReadOverseas: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const baseAssociation = ref<PlatformInboundCodeAssociation | null>(null)
const overseas = ref<OverseasInboundCodeRelationship | null>(null)
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
const baseError = ref<ApiErrorDetails | null>(null)
const overseasError = ref<ApiErrorDetails | null>(null)
const loadingAssociationError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
let serial = 0
const canReadBase = computed(() => props.canReadBase)
const canReadLoading = computed(() => props.canReadLoading)
const canReadOverseas = computed(() => props.canReadOverseas)
const normalizedCode = computed(() => props.inboundCode.trim())
function display(value: unknown, fallback = '—') { return String(value ?? '').trim() || fallback }
function close(value: boolean) { emit('update:modelValue', value) }
async function load() {
const code = normalizedCode.value
const request = ++serial
baseAssociation.value = null; overseas.value = null; loadingAssociation.value = null; baseError.value = null; overseasError.value = null; loadingAssociationError.value = null
if (!code || (!canReadBase.value && !canReadLoading.value && !canReadOverseas.value)) return
loading.value = true
const tasks: Promise<void>[] = []
if (canReadBase.value) tasks.push(getPlatformInboundCodeAssociation(code).then((value) => { if (request === serial) baseAssociation.value = value }).catch((cause) => { if (request === serial) baseError.value = getApiErrorDetails(cause, '订单基础关联数据加载失败') }))
if (canReadLoading.value) tasks.push(getLoadingAssociationSummary(code).then((value) => { if (request === serial) loadingAssociation.value = value }).catch((cause) => { if (request === serial) loadingAssociationError.value = getApiErrorDetails(cause, '装柜与PDA关联数据加载失败') }))
if (canReadOverseas.value) tasks.push(getOverseasInboundCodeRelationship(code).then((value) => { if (request === serial) overseas.value = value }).catch((cause) => { if (request === serial) overseasError.value = getApiErrorDetails(cause, '海外入仓关联数据加载失败') }))
await Promise.all(tasks)
if (request === serial) loading.value = false
}
watch(() => [props.modelValue, props.inboundCode, props.canReadBase, props.canReadLoading, props.canReadOverseas] as const, ([open]) => {
if (open) void load()
else serial += 1
})
</script>
<template>
<AppDrawer :model-value="modelValue" :title="`入仓码关联数据 · ${normalizedCode || '未提供'}`" size="min(860px, 94vw)" :loading="loading" :show-footer="false" destroy-on-close @update:model-value="close">
<el-alert v-if="!normalizedCode" type="warning" :closable="false" show-icon title="缺少入仓码,已停止查询关联数据。" />
<el-alert v-else-if="!canReadBase && !canReadLoading && !canReadOverseas" type="warning" :closable="false" show-icon title="当前账号无入仓关联数据查看权限。" />
<div v-else class="relationship-sections">
<section v-if="canReadBase" class="relationship-section" aria-label="订单基础关联">
<header><h3>订单基础信息</h3><StatusBadge v-if="baseAssociation?.orderStatus" :status="baseAssociation.orderStatus" /></header>
<LoadErrorState v-if="baseError" title="订单基础关联加载失败" :message="baseError.message" @retry="load"><template #details><ApiErrorMeta :details="baseError" /></template></LoadErrorState>
<dl v-else-if="baseAssociation" class="compact-facts">
<div><dt>入仓码</dt><dd :title="display(baseAssociation.inboundCode)">{{ display(baseAssociation.inboundCode) }}</dd></div><div><dt>订单编码</dt><dd :title="display(baseAssociation.orderNo, '订单编码待补充')">{{ display(baseAssociation.orderNo, '订单编码待补充') }}</dd></div>
<div><dt>客户</dt><dd :title="display(baseAssociation.customerName, '客户名称待补充')">{{ display(baseAssociation.customerName, '客户名称待补充') }}</dd></div><div><dt>目的类型</dt><dd>{{ display(baseAssociation.destinationType) }}</dd></div>
<div><dt>目的国家</dt><dd>{{ display(baseAssociation.destinationCountryCode) }}</dd></div><div><dt>目的仓</dt><dd>{{ display(baseAssociation.destinationWarehouseName, '目的仓名称待补充') }}<small v-if="baseAssociation.destinationWarehouseCode">{{ baseAssociation.destinationWarehouseCode }}</small></dd></div>
</dl>
</section>
<section v-if="canReadLoading" class="relationship-section" aria-label="装柜与PDA关联">
<header><h3>装柜与PDA</h3><StatusBadge v-if="loadingAssociation?.loadingStatus" :status="loadingAssociation.loadingStatus" /></header>
<LoadErrorState v-if="loadingAssociationError" title="装柜关联加载失败" :message="loadingAssociationError.message" @retry="load"><template #details><ApiErrorMeta :details="loadingAssociationError" /></template></LoadErrorState>
<dl v-else-if="loadingAssociation" class="compact-facts">
<div><dt>订单编码</dt><dd :title="display(loadingAssociation.orderNo, '订单编码待补充')">{{ display(loadingAssociation.orderNo, '订单编码待补充') }}</dd></div><div><dt>装柜计划</dt><dd :title="display(loadingAssociation.loadingPlanNo, '尚未进入装柜')">{{ display(loadingAssociation.loadingPlanNo, '尚未进入装柜') }}</dd></div>
<div><dt>柜号</dt><dd :title="display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号')">{{ display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号') }}</dd></div><div><dt>同柜组</dt><dd :title="display(loadingAssociation.coLoadGroupNo)">{{ display(loadingAssociation.coLoadGroupNo) }}</dd></div>
<div><dt>装柜状态</dt><dd>{{ display(loadingAssociation.loadingStatus) }}</dd></div><div><dt>PDA任务状态</dt><dd>{{ display(loadingAssociation.pdaTaskStatus, '尚未生成PDA任务') }}</dd></div>
</dl>
</section>
<section v-if="canReadOverseas" class="relationship-section" aria-label="海外入仓关联">
<header><h3>海外入仓与库存</h3><StatusBadge v-if="overseas?.overseasReceivingStatus" :status="overseas.overseasReceivingStatus" /></header>
<LoadErrorState v-if="overseasError" title="海外关联加载失败" :message="overseasError.message" @retry="load"><template #details><ApiErrorMeta :details="overseasError" /></template></LoadErrorState>
<el-empty v-else-if="overseas && !overseas.found" description="服务端未找到该入仓码的海外关联数据" :image-size="56" />
<template v-else-if="overseas">
<dl class="compact-facts"><div><dt>客户</dt><dd>{{ display(overseas.customer?.customerName, '客户名称待补充') }}</dd></div><div><dt>海外仓</dt><dd>{{ display(overseas.overseasWarehouse?.warehouseName, '仓库名称待补充') }}<small v-if="overseas.overseasWarehouse?.warehouseCode">{{ overseas.overseasWarehouse.warehouseCode }}</small></dd></div></dl>
<div class="compact-list"><strong>尾程订单</strong><span :title="overseas.taillegOrders.map((item) => item.orderNo).join('、')">{{ overseas.taillegOrders.map((item) => `${item.orderNo}${item.orderStatus ? `(${item.orderStatus})` : ''}`).filter(Boolean).join('、') || '—' }}</span></div>
<div class="compact-list"><strong>入库计划</strong><span :title="overseas.inboundPlans.map((item) => item.planNo).join('、')">{{ overseas.inboundPlans.map((item) => `${item.planNo}${item.planStatus ? `(${item.planStatus})` : ''}${item.receivingStatus ? ` · ${item.receivingStatus}` : ''}`).join('、') || '—' }}</span></div>
<div class="compact-list"><strong>托盘号</strong><span :title="overseas.palletCodes.join('、')">{{ overseas.palletCodes.join('、') || '—' }}</span></div>
<el-table v-if="overseas.inventory.length" :data="overseas.inventory" size="small" border class="inventory-table">
<el-table-column prop="skuCode" label="SKU编码" min-width="150" /><el-table-column label="产品名称" min-width="190"><template #default="{ row }">{{ display(row.productNameZh || row.productNameEn) }}</template></el-table-column><el-table-column prop="packageCount" label="库存箱数" width="110" align="right" />
</el-table>
</template>
</section>
</div>
</AppDrawer>
</template>
<style scoped>
.relationship-sections{display:grid;gap:14px}.relationship-section{border:1px solid var(--cc-border);border-radius:10px;padding:14px;background:var(--cc-surface)}.relationship-section>header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.relationship-section h3{margin:0;font-size:15px}.compact-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border)}.compact-facts>div{display:grid;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:9px 10px;background:var(--cc-surface)}.compact-facts dt{color:var(--cc-muted)}.compact-facts dd{min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compact-facts small{display:block;overflow:hidden;color:var(--cc-muted);margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.compact-list{display:grid;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:9px 10px;border-top:1px solid var(--cc-border)}.compact-list span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.inventory-table{margin-top:10px}@media(max-width:720px){.compact-facts{grid-template-columns:1fr}}
</style>