feat(client): show inbound business relationships
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./inboundCodeAssociations.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('queries the customer-scoped inbound-code association without browser-selected customer scope', () => {
|
||||
assert.match(source, /method: 'GET'/)
|
||||
assert.match(source, /`\/merchant\/v1\/orders\/first-leg\/inbound-code-associations\/\$\{encodeURIComponent\(inboundCode\.trim\(\)\)\}`/)
|
||||
assert.doesNotMatch(source, /params:[\s\S]*customerId/)
|
||||
})
|
||||
|
||||
test('keeps internal association identifiers as strings without making them display keys', () => {
|
||||
for (const field of ['orderId', 'customerId']) assert.match(source, new RegExp(`${field}: string`))
|
||||
assert.match(source, /destinationWarehouseId: string \| null/)
|
||||
for (const field of ['inboundCode', 'orderNo', 'customerName', 'destinationWarehouseCode', 'destinationWarehouseName']) {
|
||||
assert.match(source, new RegExp(`${field}: string`))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
|
||||
export interface MerchantInboundCodeAssociation {
|
||||
inboundCode: string
|
||||
orderNo: string
|
||||
orderStatus: string
|
||||
orderId: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
destinationType: string
|
||||
destinationCountryCode: string | null
|
||||
destinationWarehouseId: string | null
|
||||
destinationWarehouseCode: string | null
|
||||
destinationWarehouseName: string | null
|
||||
}
|
||||
|
||||
export function getMerchantInboundCodeAssociation(inboundCode: string) {
|
||||
return apiRequest<MerchantInboundCodeAssociation>({
|
||||
method: 'GET',
|
||||
url: `/merchant/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode.trim())}`,
|
||||
})
|
||||
}
|
||||
@@ -240,7 +240,7 @@ function formatSize(bytes: number) {
|
||||
<header class="customs-summary">
|
||||
<div>
|
||||
<span>柜次</span>
|
||||
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || detail.loadingPlanId }}</strong>
|
||||
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || '—' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>资料覆盖</span>
|
||||
@@ -281,7 +281,7 @@ function formatSize(bytes: number) {
|
||||
<div>
|
||||
<strong>{{ item.source.fileName }}</strong>
|
||||
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
|
||||
<small>关联入仓码:{{ item.inboundCodes.join('、') || '-' }}</small>
|
||||
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customs-document-actions">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./InboundCodeAssociationDialog.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('loads only the customer-scoped server association and fails closed', () => {
|
||||
assert.match(source, /getMerchantInboundCodeAssociation\(inboundCode\)/)
|
||||
assert.match(source, /association\.value = null/)
|
||||
assert.match(source, /关联摘要加载失败/)
|
||||
assert.match(source, /@click="loadAssociation"/)
|
||||
assert.doesNotMatch(source, /orderId\)|customerId\)|destinationWarehouseId\)/)
|
||||
})
|
||||
|
||||
test('renders business identifiers and names without internal long identifiers', () => {
|
||||
for (const label of ['入仓码', '订单号', '订单状态', '客户名称', '目的国家', '目的仓']) {
|
||||
assert.match(source, new RegExp(label))
|
||||
}
|
||||
assert.match(source, /association\.inboundCode/)
|
||||
assert.match(source, /association\.orderNo/)
|
||||
assert.match(source, /association\.customerName/)
|
||||
assert.match(source, /destinationWarehouseCode/)
|
||||
assert.match(source, /destinationWarehouseName/)
|
||||
assert.doesNotMatch(source, /association\.(?:orderId|customerId|destinationWarehouseId)/)
|
||||
assert.match(source, /\|\| '—'/)
|
||||
})
|
||||
|
||||
test('uses a bounded centered summary dialog suitable for the desktop detail flow', () => {
|
||||
assert.match(source, /<el-dialog[^>]*width="620px"/)
|
||||
assert.match(source, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/)
|
||||
assert.match(source, /min-height: 190px/)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AlertTriangle, Link2, RefreshCw } from '@lucide/vue'
|
||||
import { FIRST_LEG_ORDER_STATUSES } from '@chongchong/shared'
|
||||
import {
|
||||
getMerchantInboundCodeAssociation,
|
||||
type MerchantInboundCodeAssociation,
|
||||
} from '@/modules/first-leg/api/inboundCodeAssociations'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
inboundCode: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const association = ref<MerchantInboundCodeAssociation | null>(null)
|
||||
let requestSequence = 0
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
function displayValue(value: string | null | undefined) {
|
||||
return value?.trim() || '—'
|
||||
}
|
||||
|
||||
function statusLabel(value: string) {
|
||||
return FIRST_LEG_ORDER_STATUSES.find((item) => item.code === value)?.label ?? displayValue(value)
|
||||
}
|
||||
|
||||
function destinationLabel(value: MerchantInboundCodeAssociation) {
|
||||
return [value.destinationWarehouseCode, value.destinationWarehouseName].filter(Boolean).join(' · ') || '—'
|
||||
}
|
||||
|
||||
async function loadAssociation() {
|
||||
const inboundCode = props.inboundCode.trim()
|
||||
const sequence = ++requestSequence
|
||||
association.value = null
|
||||
error.value = ''
|
||||
loading.value = false
|
||||
if (!inboundCode) {
|
||||
error.value = '当前订单暂无可查询的入仓码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getMerchantInboundCodeAssociation(inboundCode)
|
||||
if (sequence === requestSequence && props.modelValue) association.value = result
|
||||
} catch (reason) {
|
||||
if (sequence === requestSequence && props.modelValue) {
|
||||
error.value = reason instanceof Error ? reason.message : '入仓码关联查询失败'
|
||||
}
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.modelValue, props.inboundCode] as const, ([open]) => {
|
||||
if (open) void loadAssociation()
|
||||
else {
|
||||
requestSequence += 1
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
association.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" class="inbound-association-dialog" title="入仓码关联摘要" width="620px" append-to-body destroy-on-close>
|
||||
<div v-loading="loading" class="inbound-association-content">
|
||||
<div v-if="error" class="inbound-association-error" role="alert">
|
||||
<AlertTriangle :size="22" />
|
||||
<div><strong>关联摘要加载失败</strong><span>{{ error }}</span></div>
|
||||
<el-button :loading="loading" @click="loadAssociation"><RefreshCw :size="15" />重新加载</el-button>
|
||||
</div>
|
||||
<template v-else-if="association">
|
||||
<header class="inbound-association-heading">
|
||||
<Link2 :size="21" />
|
||||
<div><span>入仓码</span><strong>{{ displayValue(association.inboundCode) }}</strong></div>
|
||||
</header>
|
||||
<dl class="inbound-association-grid">
|
||||
<div><dt>订单号</dt><dd>{{ displayValue(association.orderNo) }}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{ statusLabel(association.orderStatus) }}</dd></div>
|
||||
<div><dt>客户名称</dt><dd>{{ displayValue(association.customerName) }}</dd></div>
|
||||
<div><dt>目的国家</dt><dd>{{ displayValue(association.destinationCountryCode) }}</dd></div>
|
||||
<div class="is-wide"><dt>目的仓</dt><dd>{{ destinationLabel(association) }}</dd></div>
|
||||
</dl>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="visible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-association-content { min-height: 190px; }
|
||||
.inbound-association-error { display: grid; min-height: 170px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 20px; border: 1px solid #efc1bc; background: #fff8f7; color: #a33b32; }
|
||||
.inbound-association-error > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.inbound-association-error span { overflow-wrap: anywhere; font-size: 12px; }
|
||||
.inbound-association-heading { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
|
||||
.inbound-association-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.inbound-association-heading span { color: var(--muted); font-size: 11px; }
|
||||
.inbound-association-heading strong { overflow-wrap: anywhere; color: var(--navy); font-size: 16px; }
|
||||
.inbound-association-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 14px 0 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
|
||||
.inbound-association-grid > div { min-width: 0; padding: 13px 14px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.inbound-association-grid .is-wide { grid-column: 1 / -1; }
|
||||
.inbound-association-grid dt { color: var(--muted); font-size: 11px; }
|
||||
.inbound-association-grid dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; font-weight: 650; }
|
||||
</style>
|
||||
@@ -223,9 +223,10 @@ test('uses only fields present on C02 rows before loading full detail', () => {
|
||||
test('shows and searches the inbound code after a draft is submitted', () => {
|
||||
assert.match(source, /label: '入仓码 \/ SKU'/)
|
||||
assert.match(source, /order\.businessSummary\.inboundCode/)
|
||||
assert.match(source, /<template #inboundCode="\{ row \}"><span class="table-ellipsis table-code"[^>]*>\{\{ displayListValue\(\(row as FirstLegOrder\)\.businessSummary\.inboundCode\) \}\}<\/span><\/template>/)
|
||||
assert.match(source, /v-if="order\.businessSummary\.inboundCode"[^>]*>入仓码/)
|
||||
assert.match(source, /v-if="detailOrder\.businessSummary\.inboundCode"><dt>入仓码<\/dt>/)
|
||||
assert.match(source, /<template #inboundCode="\{ row \}">[\s\S]*?class="inbound-code-link table-ellipsis table-code"/)
|
||||
assert.match(source, /v-if="order\.businessSummary\.inboundCode" class="order-reference inbound-code-link"/)
|
||||
assert.match(source, /<dt>入仓码<\/dt><dd><button v-if="detailOrder\.businessSummary\.inboundCode"/)
|
||||
assert.match(source, /<span v-else>—<\/span>/)
|
||||
})
|
||||
|
||||
test('keeps the seven-stage progress and business summary usable on mobile', () => {
|
||||
@@ -495,6 +496,16 @@ test('copies reactive order drafts into plain request data', () => {
|
||||
assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/)
|
||||
})
|
||||
|
||||
test('opens the server-owned inbound-code association from desktop, mobile, and order detail', () => {
|
||||
assert.match(source, /import InboundCodeAssociationDialog/)
|
||||
assert.match(source, /function openInboundCodeAssociation\(inboundCode: string\)/)
|
||||
assert.match(source, /#inboundCode[\s\S]*?openInboundCodeAssociation\(\(row as FirstLegOrder\)\.businessSummary\.inboundCode\)/)
|
||||
assert.match(source, /class="order-reference inbound-code-link"[\s\S]*?openInboundCodeAssociation\(order\.businessSummary\.inboundCode\)/)
|
||||
assert.match(source, /<dt>入仓码<\/dt><dd><button[\s\S]*?openInboundCodeAssociation\(detailOrder\.businessSummary\.inboundCode\)/)
|
||||
assert.match(source, /<InboundCodeAssociationDialog[\s\S]*?:inbound-code="inboundAssociationCode"/)
|
||||
assert.doesNotMatch(source, /openInboundCodeAssociation\([^)]*\.id\)/)
|
||||
})
|
||||
|
||||
test('opens customer customs documents only for server-confirmed loaded tax-refund orders', () => {
|
||||
assert.match(source, /order\.status !== 'LOADED'/)
|
||||
assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/)
|
||||
|
||||
@@ -9,6 +9,7 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
|
||||
import { ApiError } from '@/api/http'
|
||||
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
|
||||
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
|
||||
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
|
||||
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
|
||||
import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue'
|
||||
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
|
||||
@@ -193,6 +194,8 @@ const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null |
|
||||
const customsEligibilityPending = new Set<string>()
|
||||
const customsDialogOpen = ref(false)
|
||||
const customsDialogOrderId = ref('')
|
||||
const inboundAssociationOpen = ref(false)
|
||||
const inboundAssociationCode = ref('')
|
||||
const supportingDataLoading = ref(false)
|
||||
const supportingDataErrors = ref<string[]>([])
|
||||
const destinationWarehousesLoading = ref(false)
|
||||
@@ -335,7 +338,7 @@ const packageTypeLabels: Readonly<Record<string, string>> = {
|
||||
}
|
||||
|
||||
function displayValue(value: string | null | undefined) {
|
||||
return value?.trim() || '-'
|
||||
return value?.trim() || '—'
|
||||
}
|
||||
|
||||
function emptyDraft(): FirstLegOrderDraft {
|
||||
@@ -494,6 +497,13 @@ function openCustomsDocuments(order: FirstLegOrder) {
|
||||
customsDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openInboundCodeAssociation(inboundCode: string) {
|
||||
const code = inboundCode.trim()
|
||||
if (!code) return
|
||||
inboundAssociationCode.value = code
|
||||
inboundAssociationOpen.value = true
|
||||
}
|
||||
|
||||
function updateCustomsDetail(value: MerchantCustomsContainer) {
|
||||
if (customsDialogOrderId.value) customsByOrder[customsDialogOrderId.value] = value
|
||||
}
|
||||
@@ -522,7 +532,7 @@ const progressSummaryItems = computed(() => {
|
||||
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
|
||||
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
|
||||
{ label: '拣货进度', value: pickingProgress },
|
||||
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '待更新' },
|
||||
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '—' },
|
||||
{ label: '船期摘要', value: transportReference || '待更新' },
|
||||
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
|
||||
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
|
||||
@@ -1220,7 +1230,7 @@ function removeCargoItem(index: number) {
|
||||
}
|
||||
|
||||
function displayListValue(value: string | number | null | undefined) {
|
||||
return value === '' || value == null ? '-' : String(value)
|
||||
return value === '' || value == null ? '—' : String(value)
|
||||
}
|
||||
|
||||
function orderMatchesCargoKeyword(order: FirstLegOrder, keyword: string) {
|
||||
@@ -1653,9 +1663,18 @@ async function searchOrders(model: QueryModel) {
|
||||
</div>
|
||||
</template>
|
||||
<template #order="{ row }">
|
||||
<button class="order-number-link table-code" type="button" :title="(row as FirstLegOrder).orderNo || '未提交草稿'" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
|
||||
<button class="order-number-link table-code" type="button" :title="(row as FirstLegOrder).orderNo || '—'" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '—' }}</button>
|
||||
</template>
|
||||
<template #inboundCode="{ row }">
|
||||
<button
|
||||
v-if="(row as FirstLegOrder).businessSummary.inboundCode"
|
||||
class="inbound-code-link table-ellipsis table-code"
|
||||
type="button"
|
||||
:title="`查看入仓码 ${(row as FirstLegOrder).businessSummary.inboundCode} 的关联摘要`"
|
||||
@click="openInboundCodeAssociation((row as FirstLegOrder).businessSummary.inboundCode)"
|
||||
>{{ (row as FirstLegOrder).businessSummary.inboundCode }}</button>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template #inboundCode="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).businessSummary.inboundCode)">{{ displayListValue((row as FirstLegOrder).businessSummary.inboundCode) }}</span></template>
|
||||
<template #channel="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).channelName)">{{ displayListValue((row as FirstLegOrder).channelName) }}</span></template>
|
||||
<template #transportMode="{ row }">{{ (row as FirstLegOrder).transportMode ? getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) : '-' }}</template>
|
||||
<template #pickupMethod="{ row }">{{ pickupMethodLabels[(row as FirstLegOrder).pickupMethod] }}</template>
|
||||
@@ -1695,10 +1714,11 @@ async function searchOrders(model: QueryModel) {
|
||||
<div v-loading="store.loading" class="mobile-order-surface">
|
||||
<div v-if="filteredOrders.length" class="mobile-order-list">
|
||||
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
|
||||
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
|
||||
<span v-if="order.businessSummary.inboundCode" class="order-reference">入仓码 {{ order.businessSummary.inboundCode }}</span>
|
||||
<span class="order-reference">{{ order.referenceNo || '无 Reference ID' }}</span>
|
||||
<dl><div><dt>目的地</dt><dd>{{ order.destination || '待更新' }}</dd></div><div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[order.destinationType] }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName || '待更新' }}</dd></div><div><dt>货物摘要</dt><dd>{{ getOrderCargoSummary(order) }}</dd></div><div><dt>运输方式</dt><dd>{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '待更新' }}</dd></div><div><dt>交货方式</dt><dd>{{ pickupMethodLabels[order.pickupMethod] }}</dd></div><div><dt>预约提货时间</dt><dd>{{ order.pickupMethod === 'PICKUP' && order.requestedPickupAt ? formatDateTime(order.requestedPickupAt) : '-' }}</dd></div><div v-if="order.businessSummary.cabinetNo || order.businessSummary.containerNo"><dt>柜号 / 柜次</dt><dd>{{ order.businessSummary.cabinetNo || order.businessSummary.containerNo }}</dd></div><div v-if="order.businessSummary.vesselName || order.businessSummary.voyageNo"><dt>船名 / 航次</dt><dd>{{ [order.businessSummary.vesselName, order.businessSummary.voyageNo].filter(Boolean).join(' · ') }}</dd></div><div v-if="order.businessSummary.sailingAt"><dt>预计船期</dt><dd>{{ formatDateTime(order.businessSummary.sailingAt) }}</dd></div></dl>
|
||||
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '—' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
|
||||
<button v-if="order.businessSummary.inboundCode" class="order-reference inbound-code-link" type="button" @click="openInboundCodeAssociation(order.businessSummary.inboundCode)">入仓码 {{ order.businessSummary.inboundCode }}</button>
|
||||
<span v-else class="order-reference">入仓码 —</span>
|
||||
<span class="order-reference">{{ order.referenceNo || '跟踪编号 —' }}</span>
|
||||
<dl><div><dt>目的地</dt><dd>{{ order.destination || '待更新' }}</dd></div><div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[order.destinationType] }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName || '—' }}</dd></div><div><dt>货物摘要</dt><dd>{{ getOrderCargoSummary(order) }}</dd></div><div><dt>运输方式</dt><dd>{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '待更新' }}</dd></div><div><dt>交货方式</dt><dd>{{ pickupMethodLabels[order.pickupMethod] }}</dd></div><div><dt>预约提货时间</dt><dd>{{ order.pickupMethod === 'PICKUP' && order.requestedPickupAt ? formatDateTime(order.requestedPickupAt) : '—' }}</dd></div><div><dt>柜号 / 柜次</dt><dd>{{ order.businessSummary.cabinetNo || order.businessSummary.containerNo || '—' }}</dd></div><div v-if="order.businessSummary.vesselName || order.businessSummary.voyageNo"><dt>船名 / 航次</dt><dd>{{ [order.businessSummary.vesselName, order.businessSummary.voyageNo].filter(Boolean).join(' · ') }}</dd></div><div v-if="order.businessSummary.sailingAt"><dt>预计船期</dt><dd>{{ formatDateTime(order.businessSummary.sailingAt) }}</dd></div></dl>
|
||||
<footer>
|
||||
<span>更新于 {{ formatDateTime(order.updatedAt) }}</span>
|
||||
<div>
|
||||
@@ -1864,13 +1884,13 @@ async function searchOrders(model: QueryModel) {
|
||||
</div>
|
||||
<template v-else-if="detailOrder">
|
||||
<div class="order-detail-heading">
|
||||
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '未提交草稿' }}</h2><p>{{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div>
|
||||
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '—' }}</h2><p>{{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div>
|
||||
<el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
|
||||
</div>
|
||||
<section class="detail-section">
|
||||
<h3>订单与运输</h3>
|
||||
<dl class="order-detail-grid">
|
||||
<div v-if="detailOrder.businessSummary.inboundCode"><dt>入仓码</dt><dd>{{ detailOrder.businessSummary.inboundCode }}</dd></div>
|
||||
<div><dt>入仓码</dt><dd><button v-if="detailOrder.businessSummary.inboundCode" class="inbound-code-link table-code" type="button" @click="openInboundCodeAssociation(detailOrder.businessSummary.inboundCode)">{{ detailOrder.businessSummary.inboundCode }}</button><span v-else>—</span></dd></div>
|
||||
<div><dt>创建时间</dt><dd>{{ formatDateTime(detailOrder.createdAt) }}</dd></div>
|
||||
<div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
|
||||
<div><dt>贸易条款</dt><dd>{{ displayValue(detailOrder.tradeTerm) }}</dd></div>
|
||||
@@ -1878,7 +1898,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<div><dt>预约提货时间</dt><dd>{{ detailOrder.pickupMethod === 'PICKUP' && detailOrder.requestedPickupAt ? formatDateTime(detailOrder.requestedPickupAt) : '-' }}</dd></div>
|
||||
<div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div>
|
||||
<div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}<span v-if="detailOrder.channelName && detailOrder.channelCode" class="detail-subline">{{ detailOrder.channelCode }}</span></dd></div>
|
||||
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '-' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '仓库编码待更新' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div>
|
||||
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '—' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '—' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div>
|
||||
<div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div>
|
||||
<div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div>
|
||||
@@ -1907,9 +1927,9 @@ async function searchOrders(model: QueryModel) {
|
||||
<h3>柜次进度</h3>
|
||||
<div v-if="cabinetProgressItems.length" class="cabinet-progress-list">
|
||||
<article v-for="cabinet in cabinetProgressItems" :key="cabinet.cabinetId" class="cabinet-progress-card">
|
||||
<header><strong>{{ cabinet.cabinetNo || '柜次待分配' }}</strong><el-tag size="small" effect="plain">{{ cabinet.status || '待更新' }}</el-tag></header>
|
||||
<header><strong>{{ cabinet.cabinetNo || '—' }}</strong><el-tag size="small" effect="plain">{{ cabinet.status || '待更新' }}</el-tag></header>
|
||||
<dl>
|
||||
<div><dt>柜号</dt><dd>{{ cabinet.cabinetNo || '待分配' }}</dd></div>
|
||||
<div><dt>柜号</dt><dd>{{ cabinet.cabinetNo || '—' }}</dd></div>
|
||||
<div><dt>船名 / 航次</dt><dd>{{ [cabinet.vesselName, cabinet.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</dd></div>
|
||||
<div><dt>预计船期</dt><dd>{{ cabinet.sailingAt ? formatDateTime(cabinet.sailingAt) : '待更新' }}</dd></div>
|
||||
</dl>
|
||||
@@ -1986,6 +2006,10 @@ async function searchOrders(model: QueryModel) {
|
||||
:initial-detail="customsByOrder[customsDialogOrderId]"
|
||||
@updated="updateCustomsDetail"
|
||||
/>
|
||||
<InboundCodeAssociationDialog
|
||||
v-model="inboundAssociationOpen"
|
||||
:inbound-code="inboundAssociationCode"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -2062,6 +2086,9 @@ async function searchOrders(model: QueryModel) {
|
||||
.mobile-order-surface { display: none; min-height: 220px; }
|
||||
.mobile-order-list { display: none; }
|
||||
.order-number-link { display: block; width: 100%; max-width: 100%; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-size: 11px; font-weight: 700; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.inbound-code-link { max-width: 100%; overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; text-align: left; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 3px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.inbound-code-link:hover, .inbound-code-link:focus-visible { text-decoration-color: currentColor; }
|
||||
.inbound-code-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.order-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
.order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; }
|
||||
.order-row-actions { align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
|
||||
|
||||
@@ -78,7 +78,10 @@ test('requires the old inbound code globally only while legacy manual mode is ac
|
||||
|
||||
test('shows every created draft and keeps failed rows available for retry', () => {
|
||||
assert.match(source, /v-for="result in draftResults"/)
|
||||
assert.match(source, /入仓码将在提交订单时生成/)
|
||||
assert.match(source, /result\.orderNo \|\| '订单号 —'/)
|
||||
assert.match(source, /result\.inboundCode \? `入仓码 \$\{result\.inboundCode\}` : '入仓码 —'/)
|
||||
assert.doesNotMatch(source, /result\.orderNo \|\| `草稿 \$\{result\.draftId\}`/)
|
||||
assert.doesNotMatch(source, /item\.orderNo \|\| item\.draftId/)
|
||||
assert.match(source, /@click="editDraft\(result\)"/)
|
||||
assert.match(source, /if \(rows\.length === 0\) rows\.push\(createRow\(\)\)/)
|
||||
assert.match(source, /resultDialogOpen\.value = successes\.length > 0/)
|
||||
|
||||
@@ -394,8 +394,10 @@ async function submitQuickOrder() {
|
||||
await Promise.all(successes.map((item) => store.loadDraftDetail(item.draftId)))
|
||||
workspace.close(['first-leg-quick-order'])
|
||||
await router.push({ name: 'first-leg-orders' })
|
||||
const orderNumbers = successes.map((item) => item.orderNo || item.draftId).join('、')
|
||||
ElMessage.success(`已生成 ${successes.length} 张草稿单:${orderNumbers}`)
|
||||
const orderNumbers = successes.map((item) => item.orderNo).filter(Boolean).join('、')
|
||||
ElMessage.success(orderNumbers
|
||||
? `已生成 ${successes.length} 张草稿单:${orderNumbers}`
|
||||
: `已生成 ${successes.length} 张草稿单,订单号 —`)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '批量草稿生成失败')
|
||||
@@ -529,7 +531,7 @@ onMounted(loadChannels)
|
||||
<el-dialog v-model="resultDialogOpen" class="quick-result-dialog" title="草稿单生成结果" width="min(700px, 94vw)">
|
||||
<div class="quick-draft-results">
|
||||
<article v-for="result in draftResults" :key="result.rowId">
|
||||
<div><strong>{{ result.orderNo || `草稿 ${result.draftId}` }}</strong><span>SKU {{ result.sku }}</span><span>{{ result.inboundCode ? `入仓码 ${result.inboundCode}` : '入仓码将在提交订单时生成' }}</span></div>
|
||||
<div><strong>{{ result.orderNo || '订单号 —' }}</strong><span>SKU {{ result.sku }}</span><span>{{ result.inboundCode ? `入仓码 ${result.inboundCode}` : '入仓码 —' }}</span></div>
|
||||
<el-button type="primary" plain @click="editDraft(result)">编辑明细</el-button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const firstLeg = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
||||
const quickOrder = readFileSync(new URL('./QuickOrderView.vue', import.meta.url), 'utf8')
|
||||
const customs = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
|
||||
const overseas = readFileSync(new URL('../../overseas-warehouse/views/WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('uses business identifiers and names without visible internal-ID fallbacks', () => {
|
||||
assert.match(firstLeg, /label: '入仓码'/)
|
||||
assert.match(firstLeg, /label: '订单号'/)
|
||||
assert.match(firstLeg, /label: '柜号 \/ 柜次'/)
|
||||
assert.match(firstLeg, /label: '国内收货仓'/)
|
||||
assert.doesNotMatch(quickOrder, /result\.orderNo \|\| `草稿 \$\{result\.draftId\}`/)
|
||||
assert.doesNotMatch(quickOrder, /item\.orderNo \|\| item\.draftId/)
|
||||
assert.doesNotMatch(customs, /detail\.planNo \|\| detail\.loadingPlanId/)
|
||||
assert.doesNotMatch(overseas, /overseasWarehouseName \|\| (?:item|candidate)\.overseasWarehouseId/)
|
||||
assert.doesNotMatch(overseas, /row\(\[[^\]]*(?:item\.overseasWarehouseId|item\.operatingWarehouseId)/)
|
||||
})
|
||||
|
||||
test('keeps internal IDs only as non-rendered routing, selection, and request values', () => {
|
||||
assert.match(quickOrder, /params: \{ draftId: result\.draftId \}/)
|
||||
assert.match(customs, /uploadMerchantCustomsDocument\(detail\.value\.loadingPlanId/)
|
||||
assert.match(overseas, /:value="candidate\.orderId"/)
|
||||
assert.match(firstLeg, /detailId\.value = order\.id/)
|
||||
})
|
||||
@@ -69,21 +69,21 @@ function selectDiscardInbound(orderId: string) {
|
||||
}))
|
||||
}
|
||||
function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined }
|
||||
function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '-' : String(value)])) }
|
||||
function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '—' : String(value)])) }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.service === 'labeling') {
|
||||
const result = await listRelabels({ page: 1, size: 20, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
|
||||
submittedRows.value = result.records.map((item) => row([item.relabelNo, item.customerOrderNo, item.overseasWarehouseId, '-', item.itemCount, item.totalPackageCount, item.status, item.createdAt]))
|
||||
submittedRows.value = result.records.map((item) => row([item.relabelNo, item.customerOrderNo, '—', '—', item.itemCount, item.totalPackageCount, item.status, item.createdAt]))
|
||||
} else if (props.service === 'disposal') {
|
||||
const result = await listDiscards({ page: 1, size: 20, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
|
||||
submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || item.overseasWarehouseId, '-', item.totalPackages, `${item.currency} ${item.totalFee}`, item.status, item.createdAt]))
|
||||
submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, item.status, item.createdAt]))
|
||||
} else {
|
||||
const result = await listTransfers({ page: 1, size: 20, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, needRelabel: props.service === 'relabel', createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
|
||||
submittedRows.value = result.records.map((item) => props.service === 'transfer'
|
||||
? row([item.transferNo, item.inboundCode, item.packageCount, item.operatingWarehouseId, item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', item.status, item.createdAt])
|
||||
: row([item.transferNo, item.operatingWarehouseId, item.fbaWarehouseCode, '-', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', item.status, item.createdAt]))
|
||||
? row([item.transferNo, item.inboundCode, item.packageCount, '—', item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', item.status, item.createdAt])
|
||||
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', item.status, item.createdAt]))
|
||||
}
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '列表加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -169,7 +169,7 @@ async function openDetail(value: Record<string, string>) {
|
||||
<div class="warehouse-card warehouse-form-section"><h3>{{ props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础与派送信息' }}</h3>
|
||||
<div class="warehouse-form-grid">
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>客户单号</span><el-input v-model="form.customerNo" placeholder="请输入客户单号" /></label>
|
||||
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable :loading="discardCandidatesLoading" placeholder="请选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode}|${candidate.inboundCode}|${candidate.overseasWarehouseName || candidate.overseasWarehouseId}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示一件代发中仍有可用箱数的入库单。</small></label>
|
||||
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable :loading="discardCandidatesLoading" placeholder="请选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|${candidate.overseasWarehouseName || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示一件代发中仍有可用箱数的入库单。</small></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" placeholder="请选择"><el-option label="ONT8" value="ONT8" /><el-option label="ABE8" value="ABE8" /><el-option label="CLT2" value="CLT2" /></el-select></label>
|
||||
<label v-else-if="props.service === 'disposal'"><span>进仓编号</span><el-input v-model="form.inboundNo" readonly /></label>
|
||||
@@ -198,7 +198,7 @@ async function openDetail(value: Record<string, string>) {
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else><div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div><div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div><div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(0, -1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '-' }}</strong></div></div></div></template>
|
||||
<template v-else><div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div><div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div><div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(0, -1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div></template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user