merge origin/dev and resolve frontend conflicts

This commit is contained in:
2026-09-08 20:33:45 +08:00
23 changed files with 228 additions and 37 deletions
+11 -2
View File
@@ -39,14 +39,23 @@ export async function overseasApiRequest<T>(config: AxiosRequestConfig) {
try {
const response = await transport.request<ApiEnvelope<T>>(config)
if (!isApiEnvelope(response.data) || response.data.code !== '200') {
throw new ApiError(isApiEnvelope(response.data) ? response.data.message : '海外接口响应格式错误', isApiEnvelope(response.data) ? response.data.code : 'INVALID_RESPONSE')
throw new ApiError(
isApiEnvelope(response.data) ? response.data.message : '海外接口响应格式错误',
isApiEnvelope(response.data) ? response.data.code : 'INVALID_RESPONSE',
undefined,
undefined,
isApiEnvelope(response.data) ? response.data.traceId : null,
isApiEnvelope(response.data) ? response.data.requestId : null,
)
}
return response.data.data
} catch (error) {
if (error instanceof ApiError) throw error
if (axios.isAxiosError(error)) {
const body = error.response?.data
if (isApiEnvelope(body)) throw new ApiError(body.message, body.code, error.response?.status)
if (isApiEnvelope(body)) {
throw new ApiError(body.message, body.code, error.response?.status, undefined, body.traceId, body.requestId)
}
throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status)
}
throw new ApiError(error instanceof Error ? error.message : '海外接口请求失败')
+1
View File
@@ -57,6 +57,7 @@ declare module 'vue' {
QueryToolbar: typeof import('./components/common/QueryToolbar.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SingleLineText: typeof import('./components/common/SingleLineText.vue')['default']
WorkspaceTabs: typeof import('./components/WorkspaceTabs.vue')['default']
}
export interface GlobalDirectives {
+33 -1
View File
@@ -7,6 +7,9 @@ export interface DataTableColumn {
columnKey?: string
slotName?: string
headerSlotName?: string
singleLine?: boolean
showOverflowTooltip?: boolean
className?: string
[attribute: string]: unknown
}
@@ -88,10 +91,34 @@ watch([page, pageCount], ([value, count]) => {
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, ...attrs }: DataTableColumn) {
function columnAttrs({ key, slotName, headerSlotName, singleLine, ...attrs }: DataTableColumn) {
return attrs
}
function systemColumn(column: DataTableColumn) {
return column.type === 'selection'
|| column.type === 'index'
|| column.type === 'expand'
|| column.slotName === 'actions'
|| column.label === '操作'
}
function singleLineColumn(column: DataTableColumn) {
return column.singleLine !== false && !systemColumn(column)
}
function columnOverflowTooltip(column: DataTableColumn) {
return singleLineColumn(column) ? column.showOverflowTooltip ?? true : column.showOverflowTooltip
}
function columnClassName(column: DataTableColumn) {
return [
column.className,
systemColumn(column) ? 'data-table__system-cell' : '',
singleLineColumn(column) ? 'data-table__single-line-cell' : '',
].filter(Boolean).join(' ')
}
function columnRenderKey(column: DataTableColumn, index: number) {
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
}
@@ -119,6 +146,8 @@ function setPage(value: number) {
v-for="(column, index) in columns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
:class-name="columnClassName(column)"
:show-overflow-tooltip="columnOverflowTooltip(column)"
>
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
<slot :name="column.headerSlotName" v-bind="scope" />
@@ -168,6 +197,9 @@ function setPage(value: number) {
<style scoped>
.data-table { min-width: 0; }
.data-table :deep(.data-table__single-line-cell .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table :deep(.data-table__system-cell .cell) { white-space: nowrap; }
.data-table :deep(.data-table__system-cell .el-button + .el-button), .data-table :deep(.data-table__system-cell .el-button + .el-dropdown) { margin-left: 8px; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
withDefaults(defineProps<{
value?: string | number | null
emptyText?: string
}>(), {
value: null,
emptyText: '—',
})
</script>
<template>
<span class="single-line-text" :title="value == null || value === '' ? undefined : String(value)">
{{ value == null || value === '' ? emptyText : value }}
</span>
</template>
<style scoped>
.single-line-text {
display: block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+1
View File
@@ -217,6 +217,7 @@ export interface MerchantFirstLegOrder {
customerId: number | string | null
customerName: string
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null
lxCode?: string | null
status: ApiFirstLegStatus
transportMode?: ApiTransportMode
tradeTerm: string | null
@@ -35,3 +35,7 @@ test('keeps all backend Long identifiers as strings', () => {
assert.doesNotMatch(typesSource, /(?:id|Id): number/)
assert.doesNotMatch(typesSource, /internalNotes|statusHistory|files/)
})
test('accepts the one-click dropship palletization process status', () => {
assert.match(typesSource, /MerchantInboundProcessStatus[\s\S]*?'WAIT_PALLETIZE'[\s\S]*?'WAIT_SPLIT'/)
})
@@ -4,8 +4,11 @@ import test from 'node:test'
const source = readFileSync(new URL('./unifiedOrders.ts', import.meta.url), 'utf8')
test('keeps the merchant unified-order boundary and phase-two WAIT_SPLIT status', () => {
test('keeps the merchant unified-order boundary and one-click palletization status', () => {
assert.match(source, /const unifiedOrderBaseUrl = '\/merchant\/v1\/orders'/)
assert.match(source, /ApiUnifiedOrderMainStatus[\s\S]*?'WAIT_PALLETIZE'/)
assert.match(source, /ApiUnifiedOrderMainStatus[\s\S]*?'WAIT_SPLIT'/)
assert.match(source, /fulfillmentType\?: 'STANDARD' \| 'ONE_CLICK_DROPSHIP' \| null/)
assert.match(source, /lxCode\?: string \| null/)
assert.match(source, /mainStatus\?: ApiUnifiedOrderMainStatus/)
})
@@ -4,6 +4,7 @@ import type { ApiContainerMode, ApiDestinationType, ApiFirstLegStatus, ApiPickup
export type ApiUnifiedOrderMainStatus =
| 'PENDING_REVIEW'
| 'WAIT_MEASURE'
| 'WAIT_PALLETIZE'
| 'WAIT_SPLIT'
| 'LOADING'
| 'LOADED'
@@ -54,6 +55,8 @@ export interface MerchantUnifiedOrderRow {
orderId: number | string
tenantId: number | string
orderNo: string | null
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null
lxCode?: string | null
mainStatus: ApiUnifiedOrderMainStatus
nextAction: string | null
internalOrderStatus: ApiFirstLegStatus | null
@@ -23,7 +23,7 @@ function stageState(index: number) {
<div class="progress-heading">
<div>
<h3 id="order-progress-title">订单进度</h3>
<span>国内装柜进度及后续履约同步</span>
<span>国内入仓、组托、装柜及后续履约同步</span>
</div>
<el-tag :type="order.status === 'DELIVERED' ? 'success' : order.status === 'DRAFT' || order.status === 'PENDING_REVIEW' ? 'warning' : 'info'" effect="light">
{{ getFirstLegStatusLabel(order.status) }}
@@ -80,6 +80,8 @@ test('declares the final C02 row and detail contract in the API adapter', () =>
assert.match(unifiedApiSource, /orderId: number \| string/)
assert.match(unifiedApiSource, /mainStatus: ApiUnifiedOrderMainStatus/)
assert.match(unifiedApiSource, /nextAction: string \| null/)
assert.match(unifiedApiSource, /fulfillmentType\?: 'STANDARD' \| 'ONE_CLICK_DROPSHIP' \| null/)
assert.match(unifiedApiSource, /lxCode\?: string \| null/)
assert.match(unifiedApiSource, /customerRemark: string \| null/)
for (const field of ['pickupMethod', 'requestedPickupAt', 'containerMode', 'destinationType', 'destinationCode', 'skuNos', 'totalQuantity', 'totalNetWeightKg', 'chargeableVolumeCbm', 'inboundCompletedAt', 'shipmentId', 'referenceId']) {
assert.match(unifiedApiSource, new RegExp(`${field}:`))
@@ -117,6 +119,10 @@ test('maps C02 authoritative status, transport, cargo and pickup snapshots', ()
assert.match(storeSource, /id: String\(summary\.orderId\)/)
assert.match(storeSource, /transportMode: unifiedTransport\(summary\.containerMode\)/)
assert.match(storeSource, /status: summary\.mainStatus/)
assert.match(storeSource, /fulfillmentType: summary\.fulfillmentType \?\? 'STANDARD'/)
assert.match(storeSource, /lxCode: summary\.lxCode \?\? ''/)
assert.match(storeSource, /mapped\.fulfillmentType = detail\.summary\.fulfillmentType \?\? mapped\.fulfillmentType/)
assert.match(storeSource, /mapped\.lxCode = detail\.summary\.lxCode \?\? mapped\.lxCode/)
assert.match(storeSource, /requestedPickupAt: summary\.requestedPickupAt \?\? ''/)
assert.match(storeSource, /chargeableVolumeCbm: summary\.chargeableVolumeCbm \?\? null/)
assert.match(storeSource, /inboundedAt: summary\.inboundCompletedAt \?\? ''/)
@@ -127,6 +133,7 @@ test('maps C02 authoritative status, transport, cargo and pickup snapshots', ()
assert.match(storeSource, /summary\?\.loading\?\.pickingVersion/)
assert.match(storeSource, /summary\?\.inboundId == null \? '' : String\(summary\.inboundId\)/)
assert.match(storeSource, /customerRemark: summary\.customerRemark \?\? ''/)
assert.match(storeSource, /PALLETIZE_DROPSHIP: '一件代发组托'/)
})
test('sends a manually entered old-system inbound code with the draft payload', () => {
@@ -231,6 +231,7 @@ const unifiedActionLabels: Readonly<Record<string, string>> = {
SUBMIT_ORDER: '订单已提交',
APPROVE_ORDER: '订单审核通过',
CONFIRM_MEASUREMENT: '测量数据已确认',
PALLETIZE_DROPSHIP: '一件代发组托',
SMART_SPLIT: '分柜方案已更新',
CONFIRM_PRELOAD: '预装清单已确认',
COMPLETE_LOADING: '装柜已完成',
@@ -279,7 +280,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
return {
id: String(order.id ?? order.orderId ?? ''), orderNo: order.orderNo, referenceNo: order.referenceId ?? '',
transportMode: order.transportMode ? clientTransport(order) : null,
businessType: 'EXPORT', fulfillmentType: order.fulfillmentType ?? 'STANDARD', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode || '',
businessType: 'EXPORT', fulfillmentType: order.fulfillmentType ?? 'STANDARD', lxCode: order.lxCode ?? '', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode || '',
receivingWarehouseId: String(order.receivingWarehouseId), receivingWarehouseName: order.receivingWarehouseName,
receivingWarehouseSnapshot: orderWarehouseSnapshot(order, warehouse), expectedDepartureDate: order.expectedShipDate ?? '', etd: '', eta: '',
shipperPartyId: shipper?.id ?? '', shipperPartySnapshot: shipper, shipper: shipper?.name ?? '',
@@ -328,7 +329,7 @@ function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: Platfo
const warehouse = warehouses.find((item) => item.id === String(summary.receivingWarehouseId))
return {
id: String(summary.orderId), orderNo: summary.orderNo, referenceNo: summary.referenceId ?? '', transportMode: unifiedTransport(summary.containerMode),
businessType: 'EXPORT', origin: '', destination: summary.destinationWarehouseName ?? '',
businessType: 'EXPORT', fulfillmentType: summary.fulfillmentType ?? 'STANDARD', lxCode: summary.lxCode ?? '', origin: '', destination: summary.destinationWarehouseName ?? '',
receivingWarehouseId: summary.receivingWarehouseId == null ? '' : String(summary.receivingWarehouseId),
receivingWarehouseName: summary.receivingWarehouseName ?? '', receivingWarehouseSnapshot: warehouseSnapshot(warehouse),
expectedDepartureDate: summary.expectedShipDate ?? '', etd: '', eta: '', shipperPartyId: '', shipperPartySnapshot: null,
@@ -381,6 +382,8 @@ function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: P
const mapped = mapOrder(detail.order.order, warehouses, detail.order)
mapped.id = String(detail.summary.orderId)
mapped.status = detail.summary.mainStatus
mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType
mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode
mapped.businessSummary = businessSummary(detail.summary)
mapped.timeline = unifiedTimeline(detail)
const summary = mapUnifiedOrderRow(detail.summary, warehouses)
@@ -6,6 +6,7 @@ export type MerchantInboundProcessStatus =
| 'WAIT_PRICING'
| 'FEE_CONFIRMING'
| 'INBOUNDING'
| 'WAIT_PALLETIZE'
| 'WAIT_SPLIT'
| 'VOIDED'
@@ -5,6 +5,8 @@ import {
canClientCancelFirstLegOrder,
canClientEditFirstLegOrder,
createEmptyCargoItem,
FIRST_LEG_PROGRESS_STAGES,
getFirstLegProgressIndex,
getFirstLegStatusLabel,
getFirstLegTransportModeLabel,
summarizeFirstLegCargo,
@@ -58,12 +60,16 @@ function validDraft() {
test('uses the confirmed status and transport labels', () => {
assert.equal(getFirstLegStatusLabel('WAIT_MEASURE'), '待测量数据')
assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜')
assert.equal(getFirstLegStatusLabel('WAIT_PALLETIZE'), '待组托')
assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜')
assert.equal(getFirstLegStatusLabel('LOADING'), '装柜中')
assert.equal(getFirstLegStatusLabel('LOADED'), '装柜完成')
assert.equal(getFirstLegStatusLabel('DELIVERED'), '派送完成')
assert.equal(getFirstLegTransportModeLabel('FCL'), '海运整箱 FCL')
assert.deepEqual(FIRST_LEG_PROGRESS_STAGES.map((item) => item.code), [
'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED',
])
assert.equal(getFirstLegProgressIndex('WAIT_PALLETIZE'), 2)
})
test('only allows draft orders to be edited in the confirmed phase-one flow', () => {
@@ -72,6 +78,7 @@ test('only allows draft orders to be edited in the confirmed phase-one flow', ()
assert.equal(canClientCancelFirstLegOrder('DRAFT'), true)
assert.equal(canClientCancelFirstLegOrder('PENDING_REVIEW'), true)
assert.equal(canClientCancelFirstLegOrder('WAIT_MEASURE'), false)
assert.equal(canClientCancelFirstLegOrder('WAIT_PALLETIZE'), false)
assert.equal(canClientCancelFirstLegOrder('WAIT_SPLIT'), false)
})
@@ -199,7 +199,7 @@ test('shows one unified progress line and C02 business summaries without detail
assert.match(source, /summary\.expectedPickCount/)
assert.match(source, /summary\.deliveryTrackingNo/)
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /国内装柜进度及后续履约同步/)
assert.match(progressSource, /国内入仓、组托、装柜及后续履约同步/)
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
assert.doesNotMatch(source, /<h3>报关回执<\/h3>/)
assert.match(source, /船期摘要/)
@@ -369,6 +369,12 @@ test('distills order creation into the confirmed prototype sections with server
assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/)
})
test('shows an optional LX inbound order number for one-click dropship orders', () => {
assert.match(source, /v-if="form\.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode"/)
assert.match(source, /v-model="form\.lxCode"[^>]*placeholder="请输入LX入库单号"/)
assert.doesNotMatch(source, /label="LX入库单号"[^>]*required/)
})
test('loads only active server channels for the selected domestic warehouse', () => {
assert.match(source, /getMerchantPricingChannels\(warehouseId\)/)
assert.match(source, /const channelOptions = ref<MerchantPricingChannel\[\]>\(\[\]\)/)
@@ -1898,7 +1898,7 @@ async function searchOrders(model: QueryModel) {
<div class="order-section-heading"><div><h3>客户下单</h3><p>先选择货物如何送到国内收货仓,并填写本次运输安排。</p></div></div>
<div class="prototype-form-flow shipping-form-flow">
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX 代码" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入领星入库单 LX 代码" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
<el-form-item label="企业提货地址簿" prop="senderAddressId" required :rules="requiredSelectRules('请选择企业提货地址')" class="prototype-form-row prototype-form-row--wide address-book-row">
@@ -33,5 +33,7 @@ test('keeps overseas warehouse operations first and renders readable list values
assert.match(overseas, /function statusLabel\(value: string\)/)
assert.match(overseas, /function deliveryMethodLabel\(value: string\)/)
assert.match(overseas, /function formatDateTime\(value: string\)/)
assert.match(overseas, /import SingleLineText from '@\/components\/common\/SingleLineText\.vue'/)
assert.match(overseas, /<SingleLineText :value="row\[String\(index\)\]"/)
assert.match(overseas, /<td><button class="warehouse-link"[^>]*>.*?查看<\/button><\/td><td v-for="\(_, index\) in config\.columns\.slice\(1\)"/s)
})
@@ -17,8 +17,8 @@ export interface InboundPlanSummary {
planNo: string
inboundCode: string
orderType: string | null
warehouseCode: string | null
warehouseName: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
fbaWarehouseCode: string | null
packageCount: number
pickedPackageCount: number
@@ -52,6 +52,8 @@ export interface InboundPlanPallet {
export interface InboundPlanDetail {
planNo: string
containerNo: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
status: string
lineCount: number
palletCount: number
@@ -1,16 +1,19 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { overseasWarehouseLabel } from './inboundPlanDisplay.ts'
const api = readFileSync(new URL('./api/inboundPlans.ts', import.meta.url), 'utf8')
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
const view = readFileSync(new URL('./views/InboundOrdersView.vue', import.meta.url), 'utf8')
const overseasHttp = readFileSync(new URL('../../api/overseasHttp.ts', import.meta.url), 'utf8')
test('activates the customer-scoped overseas inbound list and detail', () => {
assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/)
assert.match(api, /\/merchant\/v1\/last-leg\/inbound-plans/)
assert.match(api, /encodeURIComponent\(planNo\)/)
assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/)
assert.doesNotMatch(view, /customerId|tenantId/)
})
test('keeps actions first and exposes filters loading errors empty state pagination and business identifiers', () => {
@@ -23,3 +26,49 @@ test('keeps actions first and exposes filters loading errors empty state paginat
assert.match(view, /detail\.planNo/)
assert.doesNotMatch(view, /planId|orderId|warehouseId|customerId/)
})
test('shows only the server-provided overseas receiving warehouse code and name', () => {
assert.equal((api.match(/overseasWarehouseCode\?: string \| null/g) || []).length, 2)
assert.equal((api.match(/overseasWarehouseName\?: string \| null/g) || []).length, 2)
assert.doesNotMatch(api, /overseasWarehouseId/)
assert.match(view, /label="海外接柜仓"/)
assert.match(view, /overseasWarehouseLabel\(scope\.row\.overseasWarehouseCode, scope\.row\.overseasWarehouseName\)/)
assert.match(view, /overseasWarehouseLabel\(detail\.overseasWarehouseCode, detail\.overseasWarehouseName\)/)
assert.doesNotMatch(view, /warehouseName \|\| warehouseCode|destinationWarehouse(?:Id|Code|Name).*overseasWarehouse/i)
})
test('renders a dash when either formal receiving warehouse field is absent', () => {
assert.equal(overseasWarehouseLabel('US-LAX-01', '洛杉矶仓'), 'US-LAX-01 · 洛杉矶仓')
assert.equal(overseasWarehouseLabel(' US-LAX-01 ', ' 洛杉矶仓 '), 'US-LAX-01 · 洛杉矶仓')
for (const pair of [
[undefined, undefined], [null, null], ['', '洛杉矶仓'], ['US-LAX-01', ''],
[undefined, '洛杉矶仓'], ['US-LAX-01', undefined], [' ', '洛杉矶仓'], ['US-LAX-01', ' '],
]) assert.equal(overseasWarehouseLabel(pair[0], pair[1]), '—')
})
test('preserves overseas API diagnostic metadata without rendering it as a business identifier', () => {
assert.match(overseasHttp, /response\.data\.traceId/)
assert.match(overseasHttp, /response\.data\.requestId/)
assert.match(overseasHttp, /body\.traceId, body\.requestId/)
assert.doesNotMatch(view, /requestId|traceId/)
})
test('renders inbound workflow statuses in Chinese and keeps desktop values on one line', () => {
for (const [status, label] of [
['PENDING_ASSIGNMENT', '待分配'], ['PDA_PENDING', '待收货'], ['ASSIGNED', '已分配'],
['SIGNED', '已签收'], ['PICKING', '拣货中'], ['PARTIAL_INBOUND', '部分入库'],
]) assert.match(view, new RegExp(`${status}: '${label}'`))
assert.match(view, /label="状态" width="120" show-overflow-tooltip/)
assert.match(view, /\.detail-grid div \{ min-width: 0;/)
assert.match(view, /\.detail-grid dd \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
})
test('uses the formal inbound status selector and presents destination fields without mixing receiving warehouses', () => {
assert.match(view, /const inboundStatusOptions = \[/)
assert.match(view, /label="作业状态"><el-select v-model="query\.status"/)
assert.doesNotMatch(view, /label="作业状态"><el-input/)
assert.match(view, /destinationWarehouseLabel\(scope\.row\.fbaWarehouseCode, scope\.row\.destinationWarehouseCode, scope\.row\.destinationWarehouseName\)/)
assert.match(view, /destinationWarehouseLabel\(scope\.row\.destinationWarehouseCode, scope\.row\.destinationWarehouseName\)/)
assert.doesNotMatch(view, /label="目的仓代码"|label="目的仓名称"/)
assert.doesNotMatch(view, /destinationWarehouseLabel\([^)]*overseasWarehouse/)
})
@@ -0,0 +1,4 @@
export function overseasWarehouseLabel(code: unknown, name: unknown) {
if (typeof code !== 'string' || !code.trim() || typeof name !== 'string' || !name.trim()) return '—'
return `${code.trim()} · ${name.trim()}`
}
@@ -2,21 +2,42 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getInboundPlan, listInboundPlans, type InboundPlanDetail, type InboundPlanSummary } from '../api/inboundPlans'
import { overseasWarehouseLabel } from '../inboundPlanDisplay'
const loading = ref(false)
const error = ref('')
const rows = ref<InboundPlanSummary[]>([])
const detail = ref<InboundPlanDetail | null>(null)
const total = ref(0)
const inboundStatusOptions = [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '已分配', value: 'ASSIGNED' },
{ label: '待收货', value: 'PDA_PENDING' },
{ label: '已签收', value: 'SIGNED' },
{ label: '拣货中', value: 'PICKING' },
{ label: '部分入库', value: 'PARTIAL_INBOUND' },
{ label: '已完成', value: 'COMPLETED' },
]
const query = reactive({
page: 1, size: 20, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '',
appointmentStatus: '', dispatchStatus: '', createdDates: [] as string[],
})
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function destinationWarehouseLabel(...values: unknown[]) {
const parts = values
.filter((value): value is string => typeof value === 'string' && Boolean(value.trim()))
.map((value) => value.trim())
return [...new Set(parts)].join(' · ') || '—'
}
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
function statusLabel(value: unknown) {
return ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功', FAILED: '预约失败', SCHEDULED: '已排车', UNSCHEDULED: '未排车' } as Record<string, string>)[String(value)] || text(value)
return ({
PENDING: '待处理', PROCESSING: '处理中', PENDING_ASSIGNMENT: '待分配', PDA_PENDING: '待收货',
ASSIGNED: '已分配', SIGNED: '已签收', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库',
COMPLETED: '已完成', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功',
FAILED: '预约失败', SCHEDULED: '已排车', UNSCHEDULED: '未排车',
} as Record<string, string>)[String(value)] || text(value)
}
function errorMessage(cause: unknown) { return cause instanceof Error ? cause.message : '请求失败,请稍后重试' }
@@ -70,7 +91,7 @@ onMounted(() => void load())
<el-form-item label="计划号"><el-input v-model.trim="query.planNo" clearable placeholder="请输入计划号" /></el-form-item>
<el-form-item label="入仓码"><el-input v-model.trim="query.inboundCode" clearable placeholder="请输入入仓码" /></el-form-item>
<el-form-item label="目的仓"><el-input v-model.trim="query.destinationWarehouseCode" clearable placeholder="请输入仓库代码" /></el-form-item>
<el-form-item label="作业状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
<el-form-item label="作业状态"><el-select v-model="query.status" clearable placeholder="全部"><el-option v-for="option in inboundStatusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
<el-form-item label="预约状态"><el-select v-model="query.appointmentStatus" clearable placeholder="全部"><el-option label="未预约" value="UNAPPOINTED" /><el-option label="待处理" value="PENDING" /><el-option label="成功" value="SUCCESS" /><el-option label="失败" value="FAILED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
<el-form-item label="排车状态"><el-select v-model="query.dispatchStatus" clearable placeholder="全部"><el-option label="未排车" value="UNSCHEDULED" /><el-option label="已排车" value="SCHEDULED" /></el-select></el-form-item>
<el-form-item label="创建时间"><el-date-picker v-model="query.createdDates" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
@@ -82,15 +103,15 @@ onMounted(() => void load())
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
<el-table-column prop="planNo" label="计划号" min-width="180" show-overflow-tooltip />
<el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="海外仓" min-width="160" show-overflow-tooltip><template #default="scope">{{ text(scope.row.warehouseName || scope.row.warehouseCode) }}</template></el-table-column>
<el-table-column label="海外接柜仓" min-width="220" show-overflow-tooltip><template #default="scope">{{ overseasWarehouseLabel(scope.row.overseasWarehouseCode, scope.row.overseasWarehouseName) }}</template></el-table-column>
<el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="scope">{{ text(scope.row.fbaWarehouseCode) }}</template></el-table-column>
<el-table-column prop="packageCount" label="计划箱数" width="100" />
<el-table-column prop="pickedPackageCount" label="已拣箱数" width="100" />
<el-table-column prop="palletCount" label="托盘数" width="90" />
<el-table-column label="状态" width="120"><template #default="scope">{{ statusLabel(scope.row.status) }}</template></el-table-column>
<el-table-column label="预约状态" width="120"><template #default="scope">{{ statusLabel(scope.row.appointmentStatus) }}</template></el-table-column>
<el-table-column label="排车状态" width="110"><template #default="scope">{{ statusLabel(scope.row.dispatchStatus) }}</template></el-table-column>
<el-table-column label="创建时间" width="180"><template #default="scope">{{ dateTime(scope.row.createdAt) }}</template></el-table-column>
<el-table-column label="状态" width="120" show-overflow-tooltip><template #default="scope">{{ statusLabel(scope.row.status) }}</template></el-table-column>
<el-table-column label="预约状态" width="120" show-overflow-tooltip><template #default="scope">{{ statusLabel(scope.row.appointmentStatus) }}</template></el-table-column>
<el-table-column label="排车状态" width="110" show-overflow-tooltip><template #default="scope">{{ statusLabel(scope.row.dispatchStatus) }}</template></el-table-column>
<el-table-column label="创建时间" width="180" show-overflow-tooltip><template #default="scope">{{ dateTime(scope.row.createdAt) }}</template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
</el-card>
@@ -100,6 +121,7 @@ onMounted(() => void load())
<el-card shadow="never">
<dl class="detail-grid">
<div><dt>计划号</dt><dd>{{ detail.planNo }}</dd></div><div><dt>集装箱号</dt><dd>{{ text(detail.containerNo) }}</dd></div>
<div><dt>海外接柜仓</dt><dd>{{ overseasWarehouseLabel(detail.overseasWarehouseCode, detail.overseasWarehouseName) }}</dd></div>
<div><dt>状态</dt><dd>{{ statusLabel(detail.status) }}</dd></div><div><dt>入仓码数</dt><dd>{{ detail.lineCount }}</dd></div>
<div><dt>托盘数</dt><dd>{{ detail.palletCount }}</dd></div><div><dt>总体积(m³)</dt><dd>{{ detail.totalVolumeCbm }}</dd></div>
<div><dt>计划箱数</dt><dd>{{ detail.expectedPackageCount }}</dd></div><div><dt>已拣箱数</dt><dd>{{ detail.pickedPackageCount }}</dd></div>
@@ -107,14 +129,14 @@ onMounted(() => void load())
</el-card>
<el-card shadow="never" header="入仓明细">
<el-table :data="detail.lines" border empty-text="暂无入仓明细">
<el-table-column prop="inboundCode" label="入仓码" min-width="180" /><el-table-column prop="destinationWarehouseCode" label="目的仓代码" min-width="140" />
<el-table-column prop="destinationWarehouseName" label="目的仓名称" min-width="160" /><el-table-column prop="requiredPackageCount" label="计划箱数" width="100" />
<el-table-column prop="inboundCode" label="入仓码" min-width="180" /><el-table-column label="目的仓" min-width="220" show-overflow-tooltip><template #default="scope">{{ destinationWarehouseLabel(scope.row.fbaWarehouseCode, scope.row.destinationWarehouseCode, scope.row.destinationWarehouseName) }}</template></el-table-column>
<el-table-column prop="requiredPackageCount" label="计划箱数" width="100" />
<el-table-column prop="plannedPalletCount" label="计划托数" width="100" /><el-table-column prop="volumeCbm" label="体积(m³)" width="110" />
</el-table>
</el-card>
<el-card shadow="never" header="托盘明细">
<el-table :data="detail.pallets" border empty-text="暂无托盘">
<el-table-column prop="palletNo" label="托盘码" min-width="180" /><el-table-column prop="destinationWarehouseCode" label="目的仓" min-width="150" />
<el-table-column prop="palletNo" label="托盘码" min-width="180" /><el-table-column label="目的仓" min-width="220" show-overflow-tooltip><template #default="scope">{{ destinationWarehouseLabel(scope.row.destinationWarehouseCode, scope.row.destinationWarehouseName) }}</template></el-table-column>
<el-table-column prop="capacityCbm" label="容量(m³)" width="120" /><el-table-column prop="allocatedVolumeCbm" label="已分配体积(m³)" width="140" />
</el-table>
</el-card>
@@ -131,7 +153,7 @@ onMounted(() => void load())
.filter-card :deep(.el-input), .filter-card :deep(.el-select) { width: 190px; }
.pagination { justify-content: flex-end; margin-top: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
.detail-grid div { padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
.detail-grid div { min-width: 0; padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; }
.detail-grid dd { margin: 0; overflow: hidden; color: #1f2937; text-overflow: ellipsis; white-space: nowrap; }
</style>
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Eye, Plus, Search, Trash2, Upload } from '@lucide/vue'
import SingleLineText from '@/components/common/SingleLineText.vue'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer,
listDiscardCandidates, listDiscards, listLastlegOrders, listRelabelInventoryCandidates, listRelabels, listTransfers,
@@ -236,15 +237,6 @@ 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 statusLabel(value: string) {
return statusOptions.value.find((option) => option.value === value)?.label || value
}
function deliveryMethodLabel(value: string) {
return ({ TRUCK: '卡车派送', EXPRESS: '快递派送', '卡车派送': '卡车派送', '快递派送': '快递派送' } as Record<string, string>)[value] || value
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : '—'
}
function warehouseDisplay(value?: string) {
if (!value) return '—'
return value.split(',').map((code) => {
@@ -252,6 +244,23 @@ function warehouseDisplay(value?: string) {
return warehouse ? warehouseLabel(warehouse) : code
}).join('、')
}
function statusLabel(value: string) {
const labels: Record<string, string> = {
PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消',
PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配',
ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货',
}
return labels[value] || value || '—'
}
function deliveryMethodLabel(value: string) {
const labels: Record<string, string> = { TRUCK: '卡车派送', EXPRESS: '快递派送' }
return labels[value] || value || '—'
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : '—'
}
async function load() {
loading.value = true
try {
@@ -409,7 +418,7 @@ function selectRelabelInventory(inboundOrderId: string) {
<div class="warehouse-filter-actions"><el-button type="primary" @click="query"><Search :size="16" />查询</el-button><el-button @click="resetFilters(); query()">重置</el-button></div>
</div>
<div v-loading="loading" class="warehouse-card warehouse-table-card">
<div class="warehouse-table-scroll"><table><thead><tr><th v-for="column in config.columns" :key="column">{{ column }}</th></tr></thead><tbody><tr v-for="(row, rowIndex) in submittedRows" :key="rowIndex"><td><button class="warehouse-link" type="button" @click="openDetail(row)"><Eye :size="15" />查看</button></td><td v-for="(_, index) in config.columns.slice(1)" :key="index">{{ row[String(index)] }}</td></tr><tr v-if="submittedRows.length === 0"><td :colspan="config.columns.length"><div class="warehouse-empty">{{ config.emptyText }}</div></td></tr></tbody></table></div>
<div class="warehouse-table-scroll"><table><thead><tr><th v-for="column in config.columns" :key="column">{{ column }}</th></tr></thead><tbody><tr v-for="(row, rowIndex) in submittedRows" :key="rowIndex"><td><button class="warehouse-link" type="button" @click="openDetail(row)"><Eye :size="15" />查看</button></td><td v-for="(_, index) in config.columns.slice(1)" :key="index"><SingleLineText :value="row[String(index)]" /></td></tr><tr v-if="submittedRows.length === 0"><td :colspan="config.columns.length"><div class="warehouse-empty">{{ config.emptyText }}</div></td></tr></tbody></table></div>
<div class="warehouse-pagination"><span>共 <strong>{{ submittedRows.length }}</strong> 条</span><el-button size="small" disabled>上一页</el-button><button type="button" class="warehouse-page-number">1</button><el-button size="small" disabled>下一页</el-button></div>
</div>
</template>