merge client changes into release/test
This commit is contained in:
@@ -22,7 +22,7 @@ test('sends multi-inbound scope, optimistic versions and idempotency for every w
|
||||
assert.match(source, /params\.append\('inboundIds', id\)/)
|
||||
assert.match(source, /expectedVersion: document\.source\.version/)
|
||||
assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/)
|
||||
assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4)
|
||||
assert.equal(source.match(/'Idempotency-Key'/g)?.length, 6)
|
||||
assert.match(source, /data\.append\('file', file\)/)
|
||||
assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,24 @@ export interface MerchantCustomsFile {
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
export interface MerchantCustomsReceiptVersion {
|
||||
version: number
|
||||
file: MerchantCustomsFile
|
||||
operatorName: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type MerchantCustomsConfirmationAction = 'CONFIRM' | 'REJECT'
|
||||
|
||||
export interface MerchantCustomsConfirmationHistory {
|
||||
id: string
|
||||
receiptVersion: number
|
||||
action: MerchantCustomsConfirmationAction
|
||||
reason: string | null
|
||||
operatorName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface MerchantCustomsInbound {
|
||||
inboundId: string
|
||||
orderId: string
|
||||
@@ -32,7 +50,12 @@ export interface MerchantCustomsDocument {
|
||||
inboundIds: string[]
|
||||
inboundCodes: string[]
|
||||
frozen: boolean
|
||||
completion: MerchantCustomsFile | null
|
||||
receipt: MerchantCustomsFile | null
|
||||
receiptStatus: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
|
||||
receiptRejectReason: string | null
|
||||
finalDocument: MerchantCustomsFile | null
|
||||
receiptHistory?: MerchantCustomsReceiptVersion[]
|
||||
confirmationHistory: MerchantCustomsConfirmationHistory[]
|
||||
}
|
||||
|
||||
export interface MerchantCustomsContainer {
|
||||
@@ -47,12 +70,32 @@ export interface MerchantCustomsContainer {
|
||||
version: number
|
||||
taxRefundCount: number
|
||||
coveredTaxRefundCount: number
|
||||
actualBoxCount: number
|
||||
inbounds: MerchantCustomsInbound[]
|
||||
documents: MerchantCustomsDocument[]
|
||||
startedAt?: string | null
|
||||
brokerSubmittedAt?: string | null
|
||||
completedAt?: string | null
|
||||
}
|
||||
|
||||
export type MerchantClearanceResultType = 'INSPECTION' | 'SUPPLEMENT_REQUIRED' | 'RELEASED' | 'DETAINED' | 'OTHER'
|
||||
export type MerchantClearanceResultStatus = 'OPEN' | 'RESOLVED' | 'CANCELLED'
|
||||
|
||||
export interface MerchantClearanceResult {
|
||||
id: string
|
||||
declarationBatchId: string
|
||||
customsContainerId: string
|
||||
resultType: MerchantClearanceResultType
|
||||
resultStatus: MerchantClearanceResultStatus
|
||||
customsEntryNo: string | null
|
||||
occurredAt: string
|
||||
resolution: string | null
|
||||
evidenceFileId: string | null
|
||||
recordedByName: string
|
||||
recordedAt: string
|
||||
previousResultId: string | null
|
||||
}
|
||||
|
||||
export interface ApiErrorMeta {
|
||||
code: string
|
||||
requestId: string | null
|
||||
@@ -100,6 +143,13 @@ export function getMerchantCustomsForOrder(orderId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getMerchantClearanceResults(orderId: string) {
|
||||
return apiRequest<MerchantClearanceResult[]>({
|
||||
method: 'GET',
|
||||
url: `/merchant/v1/customs/orders/${encodedId(orderId)}/clearance-results`,
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadMerchantCustomsDocument(planId: string, inboundIds: string[], file: File) {
|
||||
validateMerchantCustomsFile(file)
|
||||
return apiRequest<MerchantCustomsDocument>({
|
||||
@@ -183,13 +233,38 @@ export function downloadMerchantCustomsSource(document: MerchantCustomsDocument)
|
||||
return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName)
|
||||
}
|
||||
|
||||
export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) {
|
||||
export function downloadMerchantCustomsReceipt(document: MerchantCustomsDocument) {
|
||||
return download(
|
||||
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`,
|
||||
document.completion?.fileName || `customs-completion-${document.documentId}`,
|
||||
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/download`,
|
||||
document.receipt?.fileName || `customs-receipt-${document.documentId}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function downloadMerchantCustomsFinal(document: MerchantCustomsDocument) {
|
||||
return download(
|
||||
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/final/download`,
|
||||
document.finalDocument?.fileName || `customs-final-${document.documentId}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function confirmMerchantCustomsReceipt(document: MerchantCustomsDocument) {
|
||||
return apiRequest<MerchantCustomsContainer>({
|
||||
method: 'POST',
|
||||
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/confirm`,
|
||||
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-receipt-confirm-${document.documentId}`) },
|
||||
data: { expectedVersion: document.receipt?.version ?? 0, confirm: true, remark: null },
|
||||
})
|
||||
}
|
||||
|
||||
export function rejectMerchantCustomsReceipt(document: MerchantCustomsDocument, reason: string) {
|
||||
return apiRequest<MerchantCustomsContainer>({
|
||||
method: 'POST',
|
||||
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/reject`,
|
||||
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-receipt-reject-${document.documentId}`) },
|
||||
data: { expectedVersion: document.receipt?.version ?? 0, confirm: false, remark: reason },
|
||||
})
|
||||
}
|
||||
|
||||
export function merchantCustomsErrorMeta(error: unknown): ApiErrorMeta | null {
|
||||
if (!(error instanceof ApiError)) return null
|
||||
return { code: error.code, requestId: error.requestId ?? null, traceId: error.traceId ?? null }
|
||||
|
||||
@@ -441,6 +441,7 @@ export interface MerchantPriceEstimateRequest {
|
||||
destinationWarehouseId?: number | string
|
||||
destinationName?: string
|
||||
pricingTaxMode: ApiPricingTaxMode
|
||||
chargeMode?: 'KG' | 'CBM'
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
}
|
||||
|
||||
@@ -48,8 +48,14 @@ export interface MerchantUnifiedTransportSummary {
|
||||
cabinetNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
sailingAt: string | null
|
||||
portArrivalAt: string | null
|
||||
estimatedSailingAt: string | null
|
||||
actualSailingAt: string | null
|
||||
effectiveSailingAt: string | null
|
||||
effectiveSailingAtEstimated: boolean | null
|
||||
estimatedPortArrivalAt: string | null
|
||||
actualPortArrivalAt: string | null
|
||||
effectivePortArrivalAt: string | null
|
||||
effectivePortArrivalAtEstimated: boolean | null
|
||||
}
|
||||
|
||||
export interface MerchantUnifiedOrderRow {
|
||||
|
||||
@@ -5,16 +5,21 @@ import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide
|
||||
import {
|
||||
MERCHANT_CUSTOMS_STATUS_LABELS,
|
||||
deleteMerchantCustomsDocument,
|
||||
downloadMerchantCustomsCompletion,
|
||||
confirmMerchantCustomsReceipt,
|
||||
downloadMerchantCustomsReceipt,
|
||||
downloadMerchantCustomsFinal,
|
||||
downloadMerchantCustomsSource,
|
||||
getMerchantCustomsForOrder,
|
||||
getMerchantClearanceResults,
|
||||
merchantCustomsErrorMessage,
|
||||
replaceMerchantCustomsDocument,
|
||||
rejectMerchantCustomsReceipt,
|
||||
updateMerchantCustomsDocumentInbounds,
|
||||
uploadMerchantCustomsDocument,
|
||||
validateMerchantCustomsFile,
|
||||
type MerchantCustomsContainer,
|
||||
type MerchantCustomsDocument,
|
||||
type MerchantClearanceResult,
|
||||
} from '@/modules/first-leg/api/customs'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -40,6 +45,7 @@ const uploadInput = ref<HTMLInputElement>()
|
||||
const replaceInput = ref<HTMLInputElement>()
|
||||
const mutating = ref(false)
|
||||
const downloadingKey = ref('')
|
||||
const clearanceResults = ref<MerchantClearanceResult[]>([])
|
||||
|
||||
const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? [])
|
||||
const locked = computed(() => Boolean(detail.value?.startedAt) || detail.value?.status === 'DECLARING' || detail.value?.status === 'COMPLETED')
|
||||
@@ -68,6 +74,7 @@ async function loadDetail() {
|
||||
const result = await getMerchantCustomsForOrder(props.orderId)
|
||||
detail.value = result
|
||||
initializeSelection(result)
|
||||
try { clearanceResults.value = await getMerchantClearanceResults(props.orderId) } catch { clearanceResults.value = [] }
|
||||
emit('updated', result)
|
||||
} catch (error) {
|
||||
detail.value = null
|
||||
@@ -192,13 +199,13 @@ function saveDownload(blob: Blob, fileName: string) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'completion') {
|
||||
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'receipt' | 'final') {
|
||||
const key = `${document.documentId}:${kind}`
|
||||
downloadingKey.value = key
|
||||
try {
|
||||
const result = kind === 'source'
|
||||
? await downloadMerchantCustomsSource(document)
|
||||
: await downloadMerchantCustomsCompletion(document)
|
||||
: kind === 'receipt' ? await downloadMerchantCustomsReceipt(document) : await downloadMerchantCustomsFinal(document)
|
||||
saveDownload(result.blob, result.fileName)
|
||||
} catch (error) {
|
||||
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
|
||||
@@ -207,17 +214,58 @@ async function downloadFile(document: MerchantCustomsDocument, kind: 'source' |
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReceipt(document: MerchantCustomsDocument) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认已核对整份报关行回执吗?确认后平台会把确认结果发送给报关行执行。', '确认报关行回执', { type: 'warning' })
|
||||
mutating.value = true
|
||||
await confirmMerchantCustomsReceipt(document)
|
||||
ElMessage.success('回执确认成功')
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(merchantCustomsErrorMessage(error, '回执确认失败'))
|
||||
} finally { mutating.value = false }
|
||||
}
|
||||
|
||||
async function rejectReceipt(document: MerchantCustomsDocument) {
|
||||
try {
|
||||
const reason = await ElMessageBox.prompt('请填写打回原因', '打回报关行回执', { inputValidator: (value) => value.trim() ? true : '请输入打回原因' })
|
||||
mutating.value = true
|
||||
await rejectMerchantCustomsReceipt(document, reason.value)
|
||||
ElMessage.success('回执已打回')
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(merchantCustomsErrorMessage(error, '回执打回失败'))
|
||||
} finally { mutating.value = false }
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function receiptHistoryLabel(document: MerchantCustomsDocument) {
|
||||
return (document.receiptHistory || []).map((item) => `V${item.version}`).join('、') || '—'
|
||||
}
|
||||
function confirmationActionLabel(action: MerchantCustomsDocument['confirmationHistory'][number]['action']) {
|
||||
return action === 'CONFIRM' ? '确认' : '打回'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function clearanceResultLabel(value: MerchantClearanceResult) {
|
||||
return ({ INSPECTION: '查验', SUPPLEMENT_REQUIRED: '补充资料', RELEASED: '放行', DETAINED: '扣留', OTHER: '其他' } as Record<string, string>)[value.resultType] || value.resultType
|
||||
}
|
||||
|
||||
function clearanceResultStatusLabel(value: MerchantClearanceResult) {
|
||||
return value.resultStatus === 'RESOLVED' ? '已处理' : value.resultStatus === 'CANCELLED' ? '已取消' : '待处理'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -246,12 +294,16 @@ function formatSize(bytes: number) {
|
||||
<span>资料覆盖</span>
|
||||
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>实际箱数</span>
|
||||
<strong>{{ detail.actualBoxCount }}</strong>
|
||||
</div>
|
||||
<el-tag :type="statusTagType" effect="light">{{ merchantStatusLabel }}</el-tag>
|
||||
</header>
|
||||
|
||||
<div v-if="locked" class="customs-locked" role="status">
|
||||
<LockKeyhole :size="18" />
|
||||
<div><strong>报关资料已锁定</strong><span>报关开始后仅可查看和下载,不提供退回或解锁。</span></div>
|
||||
<div><strong>{{ detail.brokerSubmittedAt ? '已发送报关行执行' : '报关资料已锁定' }}</strong><span>{{ detail.brokerSubmittedAt ? '平台已将客户确认后的回执发送给报关行,等待真实报关完税文件。' : '报关开始后仅可查看和下载,不提供退回或解锁。' }}</span></div>
|
||||
</div>
|
||||
|
||||
<section class="customs-section">
|
||||
@@ -269,6 +321,20 @@ function formatSize(bytes: number) {
|
||||
<input ref="replaceInput" class="customs-hidden-input" type="file" @change="handleReplacementFile">
|
||||
</section>
|
||||
|
||||
<section class="customs-section clearance-results-section">
|
||||
<div class="customs-section-title">
|
||||
<div><h3>海关申报结果</h3><p>平台完成正式申报后,客户可查看与本订单相关的查验、补充资料和放行结果。</p></div>
|
||||
</div>
|
||||
<el-empty v-if="!clearanceResults.length" description="暂无海关结果" :image-size="48" />
|
||||
<div v-else class="clearance-result-list">
|
||||
<div v-for="item in clearanceResults" :key="item.id" class="clearance-result-row">
|
||||
<div><strong>{{ clearanceResultLabel(item) }}</strong><span>{{ formatDateTime(item.occurredAt) }} · 报关单号 {{ item.customsEntryNo || '—' }}</span></div>
|
||||
<el-tag size="small" :type="item.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ clearanceResultStatusLabel(item) }}</el-tag>
|
||||
<p v-if="item.resolution">{{ item.resolution }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="customs-section">
|
||||
<div class="customs-section-title">
|
||||
<div><h3>资料版本历史</h3><p>每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。</p></div>
|
||||
@@ -281,12 +347,20 @@ 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 v-if="item.receiptHistory?.length">报关行回执历史:{{ receiptHistoryLabel(item) }}</small>
|
||||
<small v-for="history in item.confirmationHistory" :key="history.id">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small>
|
||||
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customs-document-actions">
|
||||
<el-button link type="primary" :loading="downloadingKey === `${item.documentId}:source`" @click="downloadFile(item, 'source')"><Download :size="15" />源文件</el-button>
|
||||
<el-button v-if="detail.status === 'COMPLETED' && item.completion" link type="success" :loading="downloadingKey === `${item.documentId}:completion`" @click="downloadFile(item, 'completion')"><Download :size="15" />完成文件</el-button>
|
||||
<el-button v-if="item.receipt" link type="success" :loading="downloadingKey === `${item.documentId}:receipt`" @click="downloadFile(item, 'receipt')"><Download :size="15" />报关行回执</el-button>
|
||||
<el-button v-if="item.finalDocument" link type="success" :loading="downloadingKey === `${item.documentId}:final`" @click="downloadFile(item, 'final')"><Download :size="15" />报关完税文件</el-button>
|
||||
<template v-if="item.receipt && item.receiptStatus !== 'CONFIRMED' && detail.status === 'DECLARING'">
|
||||
<el-button link type="success" :disabled="mutating" @click="confirmReceipt(item)">确认整份回执</el-button>
|
||||
<el-button link type="danger" :disabled="mutating" @click="rejectReceipt(item)">打回回执</el-button>
|
||||
</template>
|
||||
<el-tag v-if="item.receipt" size="small" :type="item.receiptStatus === 'CONFIRMED' ? 'success' : item.receiptStatus === 'REJECTED' ? 'danger' : 'warning'">{{ item.receiptStatus === 'CONFIRMED' ? '已确认' : item.receiptStatus === 'REJECTED' ? `已打回:${item.receiptRejectReason || '待补充'}` : '待确认' }}</el-tag>
|
||||
<template v-if="canEdit && !locked && !item.frozen">
|
||||
<el-button link type="primary" :disabled="mutating" @click="beginEditInbounds(item)"><Pencil :size="15" />改关联</el-button>
|
||||
<el-button link type="primary" :disabled="mutating" @click="chooseReplacement(item)"><Upload :size="15" />覆盖</el-button>
|
||||
@@ -319,6 +393,11 @@ function formatSize(bytes: number) {
|
||||
.customs-locked strong { color: var(--navy); font-size: 12px; }
|
||||
.customs-locked span { margin-top: 3px; font-size: 11px; }
|
||||
.customs-section { margin-top: 18px; }
|
||||
.clearance-result-list { display:grid; gap:10px; }
|
||||
.clearance-result-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px 14px; padding:12px 14px; border:1px solid var(--border); background:#fbfcfe; }
|
||||
.clearance-result-row strong,.clearance-result-row span { display:block; }
|
||||
.clearance-result-row span { margin-top:4px; color:var(--muted); font-size:11px; }
|
||||
.clearance-result-row p { grid-column:1 / -1; margin:0; color:var(--navy); font-size:12px; }
|
||||
.customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
|
||||
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
|
||||
.customs-section-title p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, Ship } from '@lucide/vue'
|
||||
import { Calculator, FileText, Ship } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const firstLegModule = {
|
||||
@@ -44,5 +44,21 @@ export const firstLegModule = {
|
||||
release: 'active',
|
||||
meta: { title: '快速下单', description: '按渠道、目的仓和产品 SKU 快速生成订单草稿', permission: 'order:first-leg:quick-create' },
|
||||
},
|
||||
{
|
||||
path: 'first-leg/customs-documents',
|
||||
name: 'first-leg-customs-documents',
|
||||
component: () => import('./views/CustomsDocumentsView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '报关资料', description: '管理装柜后的退税报关资料与回执', permission: 'order:first-leg:customs-list' },
|
||||
navigation: { key: 'first-leg-customs-documents', title: '报关资料', icon: FileText },
|
||||
},
|
||||
{
|
||||
path: 'first-leg/sailing-prices',
|
||||
name: 'first-leg-sailing-prices',
|
||||
component: () => import('./views/SailingPricesView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '船期价格', description: '查询渠道、参考船期与预估运费', permission: 'order:first-leg:pricing-list' },
|
||||
navigation: { key: 'first-leg-sailing-prices', title: '船期价格', icon: Calculator },
|
||||
},
|
||||
],
|
||||
} satisfies ClientModuleManifest
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import ts from 'typescript'
|
||||
|
||||
const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
|
||||
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
|
||||
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('renders estimated sailing from the current API without substituting actual departure', () => {
|
||||
const mapper = storeSource.slice(storeSource.indexOf('function businessSummary('), storeSource.indexOf('\nconst unifiedActionLabels'))
|
||||
const javascript = ts.transpileModule(mapper, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const map = runInNewContext(`${javascript}; businessSummary`)
|
||||
const transport = {
|
||||
vesselName: 'REASSIGNED', voyageNo: 'V02',
|
||||
estimatedSailingAt: '2026-10-07T08:00:00', actualSailingAt: '2026-10-08T09:00:00',
|
||||
effectiveSailingAt: '2026-10-08T09:00:00', effectiveSailingAtEstimated: false,
|
||||
estimatedPortArrivalAt: '2026-10-27T08:00:00', actualPortArrivalAt: null,
|
||||
}
|
||||
const mapped = map({ transport })
|
||||
assert.equal(mapped.estimatedSailingAt, transport.estimatedSailingAt)
|
||||
assert.equal(mapped.estimatedPortArrivalAt, transport.estimatedPortArrivalAt)
|
||||
assert.equal(mapped.vesselName, 'REASSIGNED')
|
||||
assert.equal(mapped.voyageNo, 'V02')
|
||||
assert.equal(map().estimatedSailingAt, '')
|
||||
assert.equal(map({ transport: null }).estimatedPortArrivalAt, '')
|
||||
})
|
||||
|
||||
test('maps the first-leg aggregate list fields without loading detail rows', () => {
|
||||
assert.match(apiSource, /interface MerchantFirstLegOrderRow/)
|
||||
assert.match(apiSource, /skuNos: string \| string\[\] \| null/)
|
||||
|
||||
@@ -220,8 +220,8 @@ function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['busi
|
||||
cabinetNo: summary?.transport?.cabinetNo ?? '',
|
||||
vesselName: summary?.transport?.vesselName ?? '',
|
||||
voyageNo: summary?.transport?.voyageNo ?? '',
|
||||
sailingAt: summary?.transport?.sailingAt ?? '',
|
||||
portArrivalAt: summary?.transport?.portArrivalAt ?? '',
|
||||
estimatedSailingAt: summary?.transport?.estimatedSailingAt ?? '',
|
||||
estimatedPortArrivalAt: summary?.transport?.estimatedPortArrivalAt ?? '',
|
||||
inboundId: summary?.inboundId == null ? '' : String(summary.inboundId),
|
||||
inboundVersion: summary?.inboundVersion ?? null,
|
||||
inboundCode: summary?.inboundCode ?? '',
|
||||
|
||||
@@ -87,8 +87,8 @@ export interface FirstLegOrderBusinessSummary {
|
||||
cabinetNo: string
|
||||
vesselName: string
|
||||
voyageNo: string
|
||||
sailingAt: string
|
||||
portArrivalAt: string
|
||||
estimatedSailingAt: string
|
||||
estimatedPortArrivalAt: string
|
||||
inboundId: string
|
||||
inboundVersion: number | null
|
||||
inboundCode: string
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { FileText, RefreshCcw } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useFirstLegOrdersStore } from '../public'
|
||||
import { getMerchantCustomsForOrder, type MerchantCustomsContainer } from '../api/customs'
|
||||
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
|
||||
import type { FirstLegOrder } from '../types/firstLegOrder'
|
||||
|
||||
const store = useFirstLegOrdersStore()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const keyword = ref('')
|
||||
const records = ref<Array<{ order: FirstLegOrder; detail: MerchantCustomsContainer }>>([])
|
||||
const dialogOpen = ref(false)
|
||||
const selectedOrderId = ref('')
|
||||
const selectedDetail = ref<MerchantCustomsContainer | null>(null)
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
const query = keyword.value.trim().toLowerCase()
|
||||
if (!query) return records.value
|
||||
return records.value.filter(({ order, detail }) => [order.orderNo, order.businessSummary.inboundCode, detail.containerNo, detail.cabinetNo].some((value) => String(value ?? '').toLowerCase().includes(query)))
|
||||
})
|
||||
|
||||
function statusLabel(detail: MerchantCustomsContainer) {
|
||||
return ({ WAITING_CUSTOMER_DOCUMENTS: '待上传资料', READY_TO_START: '待报关', DECLARING: '报关中', COMPLETED: '报关完成' } as Record<string, string>)[detail.status] ?? detail.status
|
||||
}
|
||||
function statusType(detail: MerchantCustomsContainer) {
|
||||
return detail.status === 'COMPLETED' ? 'success' : detail.status === 'DECLARING' ? 'primary' : 'warning'
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try {
|
||||
await store.load()
|
||||
const candidates = store.orders.filter((order) => order.status === 'LOADED' && order.taxMode === 'TAX_REFUND')
|
||||
const results = await Promise.allSettled(candidates.map(async (order) => ({ order, detail: await getMerchantCustomsForOrder(order.id) })))
|
||||
records.value = results.flatMap((result) => result.status === 'fulfilled' && result.value.detail.inbounds.some((item) => item.orderId === result.value.order.id && item.taxMode === 'TAX_REFUND') ? [result.value] : [])
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
errorMessage.value = error instanceof Error ? error.message : '报关资料加载失败,请稍后重试'
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
function openDetail(item: unknown) {
|
||||
const value = item as { order: FirstLegOrder; detail: MerchantCustomsContainer }
|
||||
selectedOrderId.value = value.order.id; selectedDetail.value = value.detail; dialogOpen.value = true
|
||||
}
|
||||
function updateDetail(value: MerchantCustomsContainer) {
|
||||
const found = records.value.find((item) => item.order.id === selectedOrderId.value)
|
||||
if (found) found.detail = value
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customs-page">
|
||||
<section class="customs-hero"><div><span class="eyebrow">CUSTOMS DOCUMENTS</span><h1>报关资料</h1><p>查看装柜后退税订单的资料清单、文件版本与报关行回执。</p></div><el-button :loading="loading" @click="load"><RefreshCcw :size="15" />刷新</el-button></section>
|
||||
<section class="customs-toolbar"><el-input v-model="keyword" clearable placeholder="搜索订单号、入仓码或柜号" /><span>共 {{ filteredRecords.length }} 个资料包</span></section>
|
||||
<section class="customs-card">
|
||||
<el-skeleton v-if="loading" :rows="5" animated />
|
||||
<div v-else-if="errorMessage" class="state-error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load">重试</el-button></div>
|
||||
<el-empty v-else-if="!filteredRecords.length" description="暂无待处理的报关资料" />
|
||||
<el-table v-else :data="filteredRecords" row-key="order.id" stripe>
|
||||
<el-table-column label="操作" width="120" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)"><FileText :size="15" />查看资料</el-button></template></el-table-column>
|
||||
<el-table-column label="订单号" min-width="160"><template #default="{ row }">{{ row.order.orderNo || '—' }}</template></el-table-column>
|
||||
<el-table-column label="入仓码" min-width="150"><template #default="{ row }">{{ row.order.businessSummary.inboundCode || '—' }}</template></el-table-column>
|
||||
<el-table-column label="柜号" min-width="140"><template #default="{ row }">{{ row.detail.containerNo || row.detail.cabinetNo || '—' }}</template></el-table-column>
|
||||
<el-table-column label="实际箱数" width="100" prop="detail.actualBoxCount" />
|
||||
<el-table-column label="资料覆盖" width="110"><template #default="{ row }">{{ row.detail.coveredTaxRefundCount }} / {{ row.detail.taxRefundCount }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="statusType(row.detail)">{{ statusLabel(row.detail) }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<CustomsDocumentsDialog v-model="dialogOpen" :order-id="selectedOrderId" :can-edit="selectedDetail?.status !== 'COMPLETED'" :initial-detail="selectedDetail" @updated="updateDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customs-page { display:flex; flex-direction:column; gap:16px; }.customs-hero { display:flex; justify-content:space-between; align-items:flex-start; padding:0 2px 4px; }.eyebrow { color:#5b82c9; font-size:11px; font-weight:750; letter-spacing:.14em; }.customs-hero h1 { margin:6px 0 5px; color:#1f2f46; font-size:26px; line-height:1.25; letter-spacing:-.025em; }.customs-hero p { margin:0; color:#7d8da2; font-size:13px; }.customs-hero .el-button { margin-top:3px; border-color:#d9e3ef; border-radius:9px; background:#fff; color:#61738b; box-shadow:0 3px 8px rgba(26,55,91,.04); }.customs-toolbar { display:flex; min-height:82px; align-items:center; gap:16px; padding:16px 18px; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.customs-toolbar .el-input { width:min(520px, 100%); }.customs-toolbar :deep(.el-input__wrapper) { min-height:42px; border-radius:10px; box-shadow:0 0 0 1px #d9e3ef inset; }.customs-toolbar span { color:#7d8da2; font-size:12px; }.customs-card { min-height:320px; padding:4px; overflow:hidden; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.customs-card :deep(.el-table) { --el-table-header-bg-color:#f4f7fb; --el-table-row-hover-bg-color:#eaf2ff; --el-table-border-color:#edf1f6; color:#53647a; }.customs-card :deep(.el-table th.el-table__cell) { padding:13px 16px; color:#607089; font-size:12px; font-weight:750; }.customs-card :deep(.el-table td.el-table__cell) { height:58px; padding:13px 16px; font-size:13px; }.customs-card :deep(.el-table__row:nth-child(even)) { background:#f8fbff; }.state-error { display:flex; min-height:260px; flex-direction:column; align-items:center; justify-content:center; gap:10px; color:#b42318; }.state-error p { margin:0; }
|
||||
</style>
|
||||
@@ -700,13 +700,13 @@ test('supports the frozen customs document workflow and customer-safe downloads'
|
||||
'/merchant/v1/customs/loading-plans/',
|
||||
'/merchant/v1/customs/documents/',
|
||||
'/inbounds',
|
||||
'/completion/download',
|
||||
'/receipt/download',
|
||||
]) assert.match(customsApiSource, new RegExp(path.replaceAll('/', '\\/')))
|
||||
assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/)
|
||||
assert.match(customsApiSource, /data: \{ inboundIds, expectedVersion: document\.source\.version \}/)
|
||||
assert.match(customsApiSource, /requestId: error\.requestId/)
|
||||
assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/)
|
||||
assert.match(customsDialogSource, /detail\.status === 'COMPLETED' && item\.completion/)
|
||||
assert.match(customsDialogSource, /item\.finalDocument/)
|
||||
assert.match(customsDialogSource, /资料版本历史/)
|
||||
assert.match(customsDialogSource, /尚未上传退税报关资料/)
|
||||
assert.match(customsDialogSource, /退税报关资料加载失败/)
|
||||
@@ -729,7 +729,7 @@ test('shows authoritative warehouse, loading and sailing fields on desktop and n
|
||||
}
|
||||
assert.match(source, /businessSummary\.cabinetNo \|\| .*businessSummary\.containerNo/)
|
||||
assert.match(source, /businessSummary\.vesselName.*businessSummary\.voyageNo/s)
|
||||
assert.match(source, /businessSummary\.sailingAt/)
|
||||
assert.match(source, /businessSummary\.estimatedSailingAt/)
|
||||
})
|
||||
|
||||
test('loads complete order detail before editing and rejects an unavailable historical warehouse', () => {
|
||||
|
||||
@@ -144,7 +144,7 @@ const selectableOrderColumns: DataTableColumn[] = [
|
||||
{ key: 'expectedShipDate', label: '期望出运', width: 132, align: 'center', headerAlign: 'center', slotName: 'expectedShipDate' },
|
||||
{ key: 'cabinetNo', label: '柜号 / 柜次', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'cabinetNo' },
|
||||
{ key: 'vesselVoyage', label: '船名 / 航次', width: 160, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'vesselVoyage' },
|
||||
{ key: 'sailingAt', label: '预计船期', width: 164, align: 'center', headerAlign: 'center', slotName: 'sailingAt' },
|
||||
{ key: 'estimatedSailingAt', label: '预计船期', width: 164, align: 'center', headerAlign: 'center', slotName: 'estimatedSailingAt' },
|
||||
{ key: 'createdAt', label: '创建时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'createdAt' },
|
||||
{ key: 'updatedAt', label: '更新时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'updatedAt' },
|
||||
]
|
||||
@@ -429,6 +429,12 @@ function quickOrderDraft() {
|
||||
} catch {
|
||||
// Keep the standard empty row when quick-order data is malformed.
|
||||
}
|
||||
const pricingTaxMode = String(route.query.pricingTaxMode ?? '')
|
||||
if (pricingTaxMode === 'TAX_INCLUDED' || pricingTaxMode === 'TAX_EXCLUDED') draft.pricingTaxMode = pricingTaxMode
|
||||
const grossWeightKg = Number(route.query.grossWeightKg)
|
||||
const volumeCbm = Number(route.query.volumeCbm)
|
||||
if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) draft.items[0].grossWeightKg = grossWeightKg
|
||||
if (Number.isFinite(volumeCbm) && volumeCbm > 0) draft.items[0].volumeCbm = volumeCbm
|
||||
return draft
|
||||
}
|
||||
|
||||
@@ -566,7 +572,7 @@ const progressSummaryItems = computed(() => {
|
||||
const cabinetProgressItems = computed(() => {
|
||||
const summary = detailOrder.value?.businessSummary
|
||||
if (!summary || (!summary.cabinetNo && !summary.containerNo)) return []
|
||||
return [{ cabinetId: summary.cabinetNo || summary.containerNo, cabinetNo: summary.cabinetNo || summary.containerNo, status: summary.loadingStatus || '待更新', vesselName: summary.vesselName, voyageNo: summary.voyageNo, sailingAt: summary.sailingAt }]
|
||||
return [{ cabinetId: summary.cabinetNo || summary.containerNo, cabinetNo: summary.cabinetNo || summary.containerNo, status: summary.loadingStatus || '待更新', vesselName: summary.vesselName, voyageNo: summary.voyageNo, estimatedSailingAt: summary.estimatedSailingAt }]
|
||||
})
|
||||
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
|
||||
const selectedDestinationWarehouse = computed(() => store.warehouses.find((item) => item.id === form.destinationWarehouseId && item.warehouseType === form.destinationType))
|
||||
@@ -971,6 +977,7 @@ function createEstimateRequest(): MerchantPriceEstimateRequest | null {
|
||||
destinationWarehouseId: form.destinationType === 'OVERSEAS' ? form.destinationWarehouseId : undefined,
|
||||
destinationName: form.destinationWarehouseName || undefined,
|
||||
pricingTaxMode: form.pricingTaxMode,
|
||||
chargeMode: form.pricingTaxMode === 'TAX_INCLUDED' ? 'KG' : 'CBM',
|
||||
actualWeightKg: summary.grossWeightKg,
|
||||
actualVolumeCbm: summary.volumeCbm,
|
||||
}
|
||||
@@ -1267,6 +1274,11 @@ async function autofillWarehouseSelections() {
|
||||
if (form.receivingWarehouseId) {
|
||||
await loadSelectedWarehouse(form.receivingWarehouseId)
|
||||
}
|
||||
if (form.destinationType !== 'PRIVATE' && !form.destinationWarehouseId && form.amazonWarehouseCode) {
|
||||
const destinationCode = form.amazonWarehouseCode.trim().toUpperCase()
|
||||
const matchedDestination = availableDestinationWarehouses.value.find((warehouse) => warehouse.code.trim().toUpperCase() === destinationCode)
|
||||
if (matchedDestination) await selectDestinationWarehouse(matchedDestination.id)
|
||||
}
|
||||
if (form.destinationType !== 'PRIVATE' && !form.destinationWarehouseId && availableDestinationWarehouses.value.length === 1) {
|
||||
await selectDestinationWarehouse(availableDestinationWarehouses.value[0].id)
|
||||
}
|
||||
@@ -2136,7 +2148,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<template #expectedShipDate="{ row }">{{ (row as FirstLegOrder).expectedDepartureDate ? formatDateTime((row as FirstLegOrder).expectedDepartureDate) : '-' }}</template>
|
||||
<template #cabinetNo="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo)">{{ displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo) }}</span></template>
|
||||
<template #vesselVoyage="{ row }"><span class="table-ellipsis" :title="displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · '))">{{ displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · ')) }}</span></template>
|
||||
<template #sailingAt="{ row }">{{ (row as FirstLegOrder).businessSummary.sailingAt ? formatDateTime((row as FirstLegOrder).businessSummary.sailingAt) : '-' }}</template>
|
||||
<template #estimatedSailingAt="{ row }">{{ (row as FirstLegOrder).businessSummary.estimatedSailingAt ? formatDateTime((row as FirstLegOrder).businessSummary.estimatedSailingAt) : '-' }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime((row as FirstLegOrder).createdAt) }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template>
|
||||
<template #empty>
|
||||
@@ -2205,8 +2217,8 @@ async function searchOrders(model: QueryModel) {
|
||||
<div class="address-book-control"><el-select v-model="form.privateDestinationAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的私人仓地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="form.privateDestinationAddressId" link type="danger" @click="removeOrderAddress('PRIVATE_DESTINATION')">停用</el-button></div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.destinationType !== 'PRIVATE'" :label="form.destinationType === 'FBA' ? 'FBA 仓' : '海外仓'" prop="destinationWarehouseId" required :rules="requiredSelectRules('请选择平台维护的目的仓')" class="prototype-form-row"><el-select v-model="form.destinationWarehouseId" filterable :loading="destinationWarehousesLoading" placeholder="请选择当前可送达的目的仓" @change="selectDestinationWarehouse"><el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" /></el-select><small v-if="!destinationWarehousesLoading && availableDestinationWarehouses.length === 0" class="field-guidance">平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。</small></el-form-item>
|
||||
<div v-if="form.destinationType !== 'PRIVATE' && selectedDestinationWarehouse" class="selected-destination"><MapPin :size="17" /><div><span>{{ selectedDestinationWarehouse.name }} · {{ selectedDestinationWarehouse.code }} · {{ selectedDestinationWarehouse.countryCode }}</span><small v-if="selectedDestinationWarehouse.address">仓库地址:{{ selectedDestinationWarehouse.province }}{{ selectedDestinationWarehouse.city }} {{ selectedDestinationWarehouse.address }}</small><small v-if="selectedDestinationWarehouse.contactName || selectedDestinationWarehouse.contactPhone">联系人:{{ selectedDestinationWarehouse.contactName || '-' }} {{ selectedDestinationWarehouse.contactPhone }}</small></div></div>
|
||||
<el-form-item v-if="form.destinationType !== 'PRIVATE'" :label="form.destinationType === 'FBA' ? 'FBA 仓' : '海外仓'" prop="destinationWarehouseId" required :rules="requiredSelectRules('请选择平台维护的目的仓')" class="prototype-form-row"><el-select v-model="form.destinationWarehouseId" filterable :loading="destinationWarehousesLoading" placeholder="请选择当前可送达的目的仓" @change="selectDestinationWarehouse"><el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="form.destinationType === 'FBA' ? warehouse.code : `${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" /></el-select><small v-if="!destinationWarehousesLoading && availableDestinationWarehouses.length === 0" class="field-guidance">平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。</small></el-form-item>
|
||||
<div v-if="form.destinationType !== 'PRIVATE' && selectedDestinationWarehouse" class="selected-destination"><MapPin :size="17" /><div><span>{{ form.destinationType === 'FBA' ? selectedDestinationWarehouse.code : `${selectedDestinationWarehouse.name} · ${selectedDestinationWarehouse.code}` }} · {{ selectedDestinationWarehouse.countryCode }}</span><small v-if="selectedDestinationWarehouse.address">仓库地址:{{ selectedDestinationWarehouse.province }}{{ selectedDestinationWarehouse.city }} {{ selectedDestinationWarehouse.address }}</small><small v-if="selectedDestinationWarehouse.contactName || selectedDestinationWarehouse.contactPhone">联系人:{{ selectedDestinationWarehouse.contactName || '-' }} {{ selectedDestinationWarehouse.contactPhone }}</small></div></div>
|
||||
<template v-if="form.destinationType === 'PRIVATE' && form.consigneePartySnapshot">
|
||||
<el-form-item label="私人地址名称" prop="destinationWarehouseName" required class="prototype-form-row"><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="例如:洛杉矶客户自有仓" @input="syncPrivateDestinationName" /></el-form-item>
|
||||
<el-form-item label="收件人姓名" prop="consigneePartySnapshot.contactName" required :rules="requiredTextRules('请输入收货联系人姓名')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" placeholder="请输入收件人姓名" /></el-form-item>
|
||||
@@ -2384,7 +2396,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<el-table v-if="cabinetProgressItems.length" :data="cabinetProgressItems" border size="small" class="cabinet-progress-table" row-key="cabinetId">
|
||||
<el-table-column prop="cabinetNo" label="柜号 / 柜次" min-width="150"><template #default="{ row }"><span class="table-code">{{ row.cabinetNo || '—' }}</span></template></el-table-column>
|
||||
<el-table-column label="船名 / 航次" min-width="180"><template #default="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</template></el-table-column>
|
||||
<el-table-column label="预计船期" width="170"><template #default="{ row }">{{ row.sailingAt ? formatDateTime(row.sailingAt) : '待更新' }}</template></el-table-column>
|
||||
<el-table-column label="预计船期" width="170"><template #default="{ row }">{{ row.estimatedSailingAt ? formatDateTime(row.estimatedSailingAt) : '待更新' }}</template></el-table-column>
|
||||
<el-table-column prop="status" label="当前节点" width="120"><template #default="{ row }"><el-tag size="small" effect="plain">{{ row.status || '待更新' }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
<p v-else class="detail-inline-empty">尚未分配柜次,后续柜次节点将在这里同步。</p>
|
||||
|
||||
@@ -509,7 +509,7 @@ onMounted(loadChannels)
|
||||
</el-form-item>
|
||||
<el-form-item v-if="destinationForm.type !== 'PRIVATE'" :label="destinationForm.type === 'FBA' ? 'FBA 仓' : '海外仓'" required>
|
||||
<el-select v-model="destinationForm.warehouseId" filterable :loading="destinationLoading" placeholder="请选择当前可送达的目的仓">
|
||||
<el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" />
|
||||
<el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="destinationForm.type === 'FBA' ? warehouse.code : `${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" />
|
||||
</el-select>
|
||||
<small v-if="!destinationLoading && availableDestinationWarehouses.length === 0">平台暂未维护可用目的仓,请联系平台管理员。</small>
|
||||
</el-form-item>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { Calculator, Search } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { estimateMerchantFirstLegPrice, getMerchantPricingChannels, getMerchantWarehouses, type MerchantPriceEstimate, type MerchantPricingChannel, type MerchantWarehouse } from '../api/firstLeg'
|
||||
|
||||
const loading = ref(false); const querying = ref(false); const errorMessage = ref('')
|
||||
const router = useRouter()
|
||||
const warehouses = ref<MerchantWarehouse[]>([]); const destinationWarehouses = ref<MerchantWarehouse[]>([]); const channels = ref<MerchantPricingChannel[]>([]); const quotes = ref<MerchantPriceEstimate[]>([])
|
||||
const quoteDialogOpen = ref(false); const originWarehouseId = ref<string | number>(''); const destinationWarehouseId = ref<string | number>(''); const weight = ref<number | undefined>(); const volume = ref<number | undefined>(); const taxMode = ref<'TAX_INCLUDED' | 'TAX_EXCLUDED'>('TAX_INCLUDED')
|
||||
const selectedDestinationWarehouse = computed(() => destinationWarehouses.value.find((item) => String(item.id) === String(destinationWarehouseId.value)))
|
||||
const destinationCode = computed(() => selectedDestinationWarehouse.value?.warehouseCode || '')
|
||||
const hasMeasureInput = computed(() => Number(weight.value || 0) > 0 || Number(volume.value || 0) > 0)
|
||||
const hasInput = computed(() => Boolean(originWarehouseId.value && destinationWarehouseId.value && hasMeasureInput.value))
|
||||
// The pricing API accepts either dimension, but normalizing both here keeps
|
||||
// every channel on the same calculation path when one field is omitted.
|
||||
const normalizedWeightKg = computed(() => weight.value || (volume.value ? volume.value * 167 : 0))
|
||||
const normalizedVolumeCbm = computed(() => volume.value || (weight.value ? weight.value / 167 : 0))
|
||||
type QuoteCard = { quote: MerchantPriceEstimate; channel: MerchantPricingChannel }
|
||||
const allQuoteCards = computed<QuoteCard[]>(() => quotes.value.map((quote) => ({ quote, channel: channels.value.find((item) => item.channelCode === quote.channelCode) })).filter((item): item is QuoteCard => Boolean(item.channel)))
|
||||
const priceWinner = computed<QuoteCard | undefined>(() => allQuoteCards.value.reduce<QuoteCard | undefined>((winner, item) => !winner || item.quote.estimatedAmount < winner.quote.estimatedAmount ? item : winner, undefined))
|
||||
const timeWinner = computed<QuoteCard | undefined>(() => allQuoteCards.value.reduce<QuoteCard | undefined>((winner, item) => { const days = transitDays(item.channel.referenceTransit); if (days === null) return winner; return !winner || days < transitDays(winner.channel.referenceTransit)! ? item : winner }, undefined))
|
||||
const quoteCards = computed(() => {
|
||||
const prioritized: QuoteCard[] = []
|
||||
if (timeWinner.value) prioritized.push(timeWinner.value)
|
||||
if (priceWinner.value && priceWinner.value.quote.channelCode !== timeWinner.value?.quote.channelCode) prioritized.push(priceWinner.value)
|
||||
const prioritizedCodes = new Set(prioritized.map((item) => item.quote.channelCode))
|
||||
const remaining = [...allQuoteCards.value].filter((item) => !prioritizedCodes.has(item.quote.channelCode)).sort((a, b) => a.quote.estimatedAmount - b.quote.estimatedAmount)
|
||||
return [...prioritized, ...remaining]
|
||||
})
|
||||
const resultSummary = computed(() => {
|
||||
const quoteUnit = (volume.value || 0) >= (weight.value || 0) / 167 ? 'CBM' : 'KG'
|
||||
const quoteQuantity = quoteUnit === 'CBM' ? volume.value : weight.value
|
||||
return `${quoteQuantity || 0} ${quoteUnit} · ${destinationCode.value || '—'} · 匹配 ${quoteCards.value.length} 个渠道`
|
||||
})
|
||||
function transitDays(value: string | null) { const match = value?.match(/\d+/); return match ? Number(match[0]) : null }
|
||||
function formatMoney(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.estimatedAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
|
||||
function formatUnitPrice(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.unitPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/${item.chargeUnit || 'CBM'}` }
|
||||
function recommendation(item: QuoteCard) { if (timeWinner.value?.quote.channelCode === item.quote.channelCode) return { label: '时间优选', className: 'time' }; if (priceWinner.value?.quote.channelCode === item.quote.channelCode) return { label: '价格优选', className: 'price' }; return undefined }
|
||||
async function loadBase() { loading.value = true; errorMessage.value = ''; try { const [originResult, destinationResult] = await Promise.all([getMerchantWarehouses({ warehouseType: 'DOMESTIC' }), getMerchantWarehouses({ warehouseType: 'FBA' })]); warehouses.value = originResult.records; destinationWarehouses.value = destinationResult.records; if (originResult.records[0]) originWarehouseId.value = originResult.records[0].id; if (destinationResult.records[0]) destinationWarehouseId.value = destinationResult.records[0].id; await loadChannels() } catch (error) { errorMessage.value = error instanceof Error ? error.message : '船期价格加载失败' } finally { loading.value = false } }
|
||||
async function loadChannels() { if (!originWarehouseId.value) return; try { channels.value = await getMerchantPricingChannels(originWarehouseId.value) } catch (error) { channels.value = []; errorMessage.value = error instanceof Error ? error.message : '船期渠道加载失败' } }
|
||||
async function query() { const destination = selectedDestinationWarehouse.value; if (!hasInput.value || !destination) { ElMessage.warning('请选择目的 FBA 仓,并填写重量或体积(填写一项即可)'); return }; if (!channels.value.length) { ElMessage.warning('暂无可查询的船期渠道'); return }; querying.value = true; quotes.value = []; try { const results = await Promise.allSettled(channels.value.map((channel) => estimateMerchantFirstLegPrice({ originWarehouseId: originWarehouseId.value, channelCode: channel.channelCode, destinationCode: destination.warehouseCode, destinationType: 'FBA', destinationWarehouseId: destination.id, destinationName: destination.warehouseName, pricingTaxMode: taxMode.value, chargeMode: taxMode.value === 'TAX_INCLUDED' ? 'KG' : 'CBM', actualWeightKg: normalizedWeightKg.value, actualVolumeCbm: normalizedVolumeCbm.value }))); quotes.value = results.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []); if (!quotes.value.length) { const failure = results.find((item): item is PromiseRejectedResult => item.status === 'rejected'); const message = failure?.reason instanceof Error ? failure.reason.message : '当前条件暂无匹配报价'; ElMessage.warning(message); return }; quoteDialogOpen.value = true } finally { querying.value = false } }
|
||||
async function placeOrder(channelCode: string, channelName: string) {
|
||||
if (!quotes.value.some((quote) => quote.channelCode === channelCode)) {
|
||||
ElMessage.warning('请先查询该渠道价格')
|
||||
return
|
||||
}
|
||||
// Release Element Plus dialog scroll locking before changing routes. If the
|
||||
// route is replaced while the close transition is still active, the dialog
|
||||
// can otherwise leave body overflow hidden on the order page.
|
||||
quoteDialogOpen.value = false
|
||||
await nextTick()
|
||||
router.push({
|
||||
name: 'first-leg-order-create',
|
||||
query: {
|
||||
quick: '1',
|
||||
receivingWarehouseId: String(originWarehouseId.value),
|
||||
channelCode,
|
||||
channelName,
|
||||
destinationType: 'FBA',
|
||||
destinationWarehouseId: String(selectedDestinationWarehouse.value!.id),
|
||||
destinationWarehouseName: selectedDestinationWarehouse.value!.warehouseName,
|
||||
amazonWarehouseCode: selectedDestinationWarehouse.value!.warehouseCode,
|
||||
destinationCountryCode: selectedDestinationWarehouse.value!.countryCode || 'US',
|
||||
pricingTaxMode: taxMode.value,
|
||||
grossWeightKg: String(normalizedWeightKg.value),
|
||||
volumeCbm: String(normalizedVolumeCbm.value),
|
||||
cargo: JSON.stringify([{ sku: '', packageCount: 1 }]),
|
||||
},
|
||||
})
|
||||
}
|
||||
watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void loadBase() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pricing-page"><section class="pricing-inquiry"><div class="inquiry-copy"><span>SAILING PRICE INQUIRY</span><h2>船期价格查询</h2><p>重量或体积填写一项即可,系统会按计价规则自动换算</p></div><el-form class="pricing-form" label-position="top"><el-form-item label="起运仓"><el-select v-model="originWarehouseId" filterable :loading="loading" placeholder="选择起运仓"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="item.id" /></el-select></el-form-item><el-form-item label="货物重量"><div class="unit-input"><el-input v-model.number="weight" type="number" min="0" placeholder="可选,输入重量" /><span>KG</span></div></el-form-item><el-form-item label="货物立方数"><div class="unit-input"><el-input v-model.number="volume" type="number" min="0" placeholder="可选,输入体积" /><span>CBM</span></div></el-form-item><el-form-item label="目的 FBA 仓"><el-select v-model="destinationWarehouseId" filterable :loading="loading" placeholder="输入或选择仓库代码"><el-option v-for="item in destinationWarehouses" :key="String(item.id)" :label="item.warehouseCode" :value="item.id" /></el-select></el-form-item><el-form-item label="报价方式"><el-radio-group v-model="taxMode"><el-radio-button value="TAX_INCLUDED" label="包税" /><el-radio-button value="TAX_EXCLUDED" label="不包税" /></el-radio-group></el-form-item><el-form-item class="query-item"><el-button type="primary" :loading="querying" @click="query"><Search :size="16" />立即查询</el-button></el-form-item></el-form>
|
||||
<el-dialog v-model="quoteDialogOpen" class="quote-dialog" :show-close="false" :lock-scroll="false" width="min(1080px, calc(100vw - 48px))" top="6vh"><template #header><div class="quote-dialog-head"><div><h3>查询结果</h3><span>{{ resultSummary }}</span></div><button type="button" class="quote-close" aria-label="关闭" @click="quoteDialogOpen = false">×</button></div></template><div class="quote-grid"><article v-for="item in quoteCards" :key="item.quote.channelCode" class="quote-card" :class="recommendation(item)?.className"><div v-if="recommendation(item)" class="quote-ribbon">{{ recommendation(item)?.label }}</div><div class="quote-card-title"><strong>{{ item.channel.channelName }}</strong><span>{{ item.quote.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }} / {{ item.quote.chargeUnit || 'CBM' }}</span></div><div class="quote-price"><b>{{ formatMoney(item.quote) }}</b><small>预估运费</small></div><div class="quote-meta"><div><span>单价</span><strong>{{ formatUnitPrice(item.quote) }}</strong></div><div><span>参考时效</span><strong>{{ item.channel.referenceTransit || '待更新' }}</strong></div><div><span>最晚进仓</span><strong>{{ item.channel.latestWarehouseEntry || '—' }}</strong></div><div><span>船期</span><strong>{{ item.channel.estimatedDepartureText || item.channel.estimatedDepartureDate || '待更新' }}</strong></div></div><el-button type="primary" plain class="quote-order" @click="placeOrder(item.quote.channelCode, item.channel.channelName)">下单</el-button></article></div></el-dialog></section>
|
||||
<section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /><el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button link type="primary" :disabled="!quotes.some((quote) => quote.channelCode === row.channelCode)" @click="placeOrder(row.channelCode, row.channelName)">下单</el-button></template></el-table-column></el-table></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pricing-page { display:flex; flex-direction:column; gap:16px; }.pricing-heading { display:flex; justify-content:space-between; align-items:flex-start; padding:0 2px; }.eyebrow { color:#5b82c9; font-size:11px; font-weight:750; letter-spacing:.14em; }.pricing-heading h1 { margin:6px 0 5px; color:#1f2f46; font-size:26px; line-height:1.25; letter-spacing:-.025em; }.pricing-heading p { margin:0; color:#7d8da2; font-size:13px; }.refresh-button { margin-top:3px; border-color:#d9e3ef; border-radius:9px; background:#fff; color:#61738b; box-shadow:0 3px 8px rgba(26,55,91,.04); }.pricing-inquiry { position:relative; overflow:hidden; padding:24px; border:1px solid rgba(99,135,255,.36); border-radius:18px; background:linear-gradient(112deg,#152754 0%,#203d8f 42%,#315bd9 75%,#4c70f5 100%); box-shadow:0 14px 36px rgba(31,61,140,.2); }.pricing-inquiry::before { content:''; position:absolute; right:-75px; top:-165px; width:410px; height:410px; border:48px solid rgba(255,255,255,.07); border-radius:50%; background:radial-gradient(circle,rgba(255,255,255,.07),transparent 64%); pointer-events:none; }.inquiry-copy { position:relative; z-index:1; margin-bottom:19px; }.inquiry-copy > span { color:rgba(255,255,255,.7); font-size:10px; font-weight:850; letter-spacing:.18em; }.inquiry-copy h2 { margin:5px 0 4px; color:#fff; font-size:22px; font-weight:850; }.inquiry-copy p { margin:0; color:rgba(255,255,255,.7); font-size:13px; }.pricing-form { position:relative; z-index:1; display:grid; grid-template-columns:minmax(130px,.9fr) minmax(120px,.75fr) minmax(120px,.75fr) minmax(170px,1.1fr) minmax(125px,.8fr) 128px; align-items:end; gap:10px; }.pricing-form :deep(.el-form-item) { margin-bottom:0; }.pricing-form :deep(.el-form-item__label) { height:auto; margin-bottom:8px; color:rgba(255,255,255,.82); font-size:12px; font-weight:750; line-height:1.2; }.pricing-form :deep(.el-input__wrapper), .pricing-form :deep(.el-select__wrapper) { min-height:48px; border:1px solid #d4e0ed; border-radius:11px; background:#fff; box-shadow:0 4px 13px rgba(45,72,105,.04); }.pricing-form :deep(.el-input__inner), .pricing-form :deep(.el-select__selected-item) { color:#24354d; font-size:14px; font-weight:600; }.unit-input { display:flex; min-height:48px; overflow:hidden; border:1px solid #d4e0ed; border-radius:11px; background:#fff; }.unit-input :deep(.el-input__wrapper) { min-height:46px; border:0; box-shadow:none; }.unit-input span { display:flex; align-items:center; padding:0 13px 0 4px; color:#7690ad; font-size:12px; font-weight:750; }.pricing-form :deep(.el-radio-group) { display:flex; min-height:48px; align-items:center; padding:0 4px; border-radius:11px; background:#fff; }.pricing-form :deep(.el-radio-button__inner) { height:40px; padding:12px 12px; border:0; border-radius:8px; color:#61738b; box-shadow:none; }.pricing-form :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { border-color:#3d70e8; background:#3d70e8; color:#fff; box-shadow:0 5px 12px rgba(37,99,235,.2); }.query-item :deep(.el-form-item__content) { display:block; }.query-item :deep(.el-button) { width:100%; min-height:48px; border:0; border-radius:11px; background:linear-gradient(135deg,#ffca4b,#ffb82e); color:#48350b; box-shadow:0 9px 20px rgba(255,185,46,.25); font-size:14px; font-weight:850; }.query-item :deep(.el-button:hover) { background:linear-gradient(135deg,#ffd363,#ffc13e); color:#48350b; transform:translateY(-1px); }.pricing-results { padding:4px; overflow:hidden; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.section-title { display:flex; align-items:center; justify-content:space-between; padding:17px 18px 13px; }.section-title h2 { margin:0 0 4px; color:#20324a; font-size:16px; }.section-title span { color:#7d8da2; font-size:12px; }.section-title svg { color:#3d70e8; }.pricing-results small { display:block; margin-top:3px; color:#7d8da2; font-size:11px; }.pricing-results :deep(.el-table) { --el-table-header-bg-color:#f4f7fb; --el-table-row-hover-bg-color:#eaf2ff; --el-table-border-color:#edf1f6; color:#53647a; }.pricing-results :deep(.el-table th.el-table__cell) { padding:13px 16px; color:#607089; font-size:12px; font-weight:750; }.pricing-results :deep(.el-table td.el-table__cell) { height:58px; padding:13px 16px; font-size:13px; }.pricing-results :deep(.el-table__row:nth-child(even)) { background:#f8fbff; }.pricing-results :deep(.el-button--link) { font-weight:750; }.muted { color:#94a0af; }
|
||||
.quote-dialog { --el-dialog-bg-color:transparent; }.quote-dialog :deep(.el-dialog) { overflow:hidden; border-radius:18px; background:linear-gradient(125deg,#182e69 0%,#274cbd 55%,#416bf1 100%); box-shadow:0 18px 52px rgba(11,31,89,.3); }.quote-dialog :deep(.el-dialog__header) { margin:0; padding:0; }.quote-dialog :deep(.el-dialog__body) { padding:0 14px 16px; }.quote-dialog-head { display:flex; align-items:flex-start; justify-content:space-between; padding:16px 18px 12px; color:#fff; }.quote-dialog-head h3 { margin:0 0 2px; font-size:19px; }.quote-dialog-head span { color:rgba(255,255,255,.72); font-size:12px; }.quote-close { width:36px; height:36px; border:1px solid #cfddf2; border-radius:10px; background:#fff; color:#8392a8; font-size:26px; line-height:29px; cursor:pointer; }.quote-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; align-items:stretch; }.quote-card { position:relative; display:flex; height:100%; min-height:240px; box-sizing:border-box; flex-direction:column; overflow:hidden; padding:18px 17px 14px; border:1px solid #d5deee; border-radius:15px; background:#f8f9fd; color:#263a55; box-shadow:0 6px 16px rgba(22,48,117,.1); }.quote-card.price { border:2px solid #ffae3c; }.quote-card.time { border:2px solid #27c28c; }.quote-ribbon { position:absolute; right:-43px; top:13px; width:130px; padding:5px 0; transform:rotate(43deg); background:#ff8d14; color:#fff; text-align:center; font-size:10px; font-weight:850; }.quote-card.time .quote-ribbon { background:#0db886; }.quote-card-title { display:flex; min-width:0; align-items:flex-start; justify-content:space-between; gap:6px; }.quote-card.price .quote-card-title, .quote-card.time .quote-card-title { padding-right:38px; }.quote-card-title strong { min-width:0; overflow-wrap:anywhere; font-size:16px; line-height:1.25; word-break:break-word; }.quote-card-title span { flex:none; padding:5px 7px; border-radius:15px; background:#edf5ff; color:#367ae0; font-size:10px; font-weight:750; white-space:nowrap; }.quote-price { display:flex; flex-shrink:0; align-items:baseline; gap:6px; margin:17px 0 15px; }.quote-price b { color:#ed5a2a; font-size:26px; line-height:1; white-space:nowrap; }.quote-price small { flex-shrink:0; color:#96a4b8; font-size:12px; }.quote-meta { display:grid; min-width:0; flex:1; grid-template-columns:minmax(0,1fr) minmax(0,1fr); align-content:start; gap:11px 10px; }.quote-meta > div { min-width:0; }.quote-meta span { display:block; margin-bottom:3px; color:#93a0b2; font-size:10px; }.quote-meta strong { display:block; min-width:0; overflow-wrap:anywhere; color:#344961; font-size:12px; line-height:1.35; word-break:break-word; }.quote-order { width:100%; min-height:32px; flex-shrink:0; margin-top:16px; border-radius:7px; font-size:12px; }.quote-card:not(.price):not(.time) .quote-order { background:#fff; }
|
||||
@media (max-width:1200px) { .pricing-form { grid-template-columns:repeat(3,minmax(0,1fr)); }.quote-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:720px) { .quote-grid { grid-template-columns:1fr; }.pricing-form { grid-template-columns:1fr; } }
|
||||
:global(.quote-dialog.el-dialog) { overflow:hidden; border-radius:18px; background:linear-gradient(125deg,#182e69 0%,#274cbd 55%,#416bf1 100%); box-shadow:0 18px 52px rgba(11,31,89,.3); }
|
||||
:global(.quote-dialog.el-dialog .el-dialog__header) { margin:0; padding:0; }
|
||||
:global(.quote-dialog.el-dialog .el-dialog__body) { padding:0 14px 16px; }
|
||||
</style>
|
||||
@@ -92,7 +92,7 @@ test('submits the complete create contract with stable retry idempotency and rec
|
||||
for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) {
|
||||
assert.match(createSource, new RegExp(`v-model="form\\.${model}"`))
|
||||
}
|
||||
assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = crypto\.randomUUID\(\)/)
|
||||
assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = createIdempotencyKey\(\)/)
|
||||
assert.match(createSource, /createDeliveryOrder\(requestPayload\(\)\)/)
|
||||
assert.match(createSource, /fees: form\.fees\.map/)
|
||||
assert.match(createSource, /if \(form\.fees\.length === 0\)/)
|
||||
|
||||
@@ -1607,7 +1607,7 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
</header>
|
||||
<div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div>
|
||||
<el-alert v-else-if="props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" class="warehouse-rejected-alert" type="error" :closable="false" show-icon title="贴标单审核已驳回" :description="selectedRelabelDetail.application.reviewRemark || '平台未填写驳回备注'" />
|
||||
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数</span><strong>{{ selectedRelabelDetail?.application.itemCount ?? '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div><div><span>转运单号</span><strong>{{ selectedDetail?.transferNo || '—' }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
|
||||
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数</span><strong>{{ selectedRelabelDetail?.application.itemCount ?? '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div><div><span>转运单号</span><strong>{{ selectedDetail?.transferNo || '—' }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-measurement-card">
|
||||
<div class="warehouse-section-heading"><h3>PDA 打托实测数据</h3><span class="warehouse-hint">PDA 作业完成即显示入库完成,客户可确认仓库实测数据</span></div>
|
||||
<div v-if="selectedDetail?.pallets?.length" class="warehouse-table-scroll"><table><thead><tr><th>托盘号</th><th>库位</th><th>箱数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th><th>体积(m³)</th><th>状态</th><th>完成时间</th></tr></thead><tbody><tr v-for="pallet in selectedDetail.pallets" :key="String(pallet.id || pallet.sequenceNo)"><td>{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}</td><td>{{ pallet.warehouseLocationCode || '—' }}</td><td>{{ pallet.cartonCount ?? '—' }}</td><td>{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}</td><td>{{ pallet.grossWeightKg ?? '—' }}</td><td>{{ pallet.volumeCbm ?? '—' }}</td><td>{{ statusLabel(pallet.status || '') }}</td><td>{{ formatDateTime(pallet.completedAt) }}</td></tr></tbody></table></div>
|
||||
|
||||
@@ -252,6 +252,7 @@ test('贴标申请详情使用紧凑订单概览并中文展示换标状态', ()
|
||||
assert.match(view, /relabelFulfillmentLabel\(selectedRelabelDetail\?\.application\.fulfillmentStatus\)/)
|
||||
assert.match(view, />\.warehouse-detail-header\+\.warehouse-card \.warehouse-detail-grid\{grid-template-columns:repeat\(4,minmax\(180px,1fr\)\)/)
|
||||
})
|
||||
|
||||
test('仓储转运详情按接口具名字段展示订单概览', () => {
|
||||
const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || ''
|
||||
assert.match(overview, /v-if="props\.service === 'transfer'"/)
|
||||
|
||||
@@ -49,6 +49,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
label: '头程订单',
|
||||
items: [
|
||||
{ code: 'order:first-leg:list', label: '查看订单', description: '查看本企业头程订单列表和详情' },
|
||||
{ code: 'order:first-leg:customs-list', label: '查看报关资料', description: '查看本企业装柜后的退税报关资料、文件版本和报关行回执' },
|
||||
{ code: 'order:first-leg:pricing-list', label: '查看船期价格', description: '查看本企业可用渠道、参考船期和服务端预估价格' },
|
||||
{ code: 'order:first-leg:create', label: '创建订单', description: '新建并保存订单草稿' },
|
||||
{ code: 'order:first-leg:quick-create', label: '快速下单', description: '使用快速下单页面生成订单草稿,不包含提交订单权限' },
|
||||
{ code: 'order:first-leg:edit', label: '编辑订单', description: '编辑本企业尚未提交的订单草稿' },
|
||||
@@ -134,6 +136,8 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'merchant:finance:account:view': ['merchant:finance:account:view'],
|
||||
'merchant:finance:recharge:create': ['merchant:finance:recharge:create'],
|
||||
'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'],
|
||||
'order:first-leg:customs-list': ['merchant:order:list', 'merchant:order:view'],
|
||||
'order:first-leg:pricing-list': ['merchant:order:list', 'merchant:order:view'],
|
||||
'order:first-leg:create': ['merchant:order:create'],
|
||||
'order:first-leg:quick-create': ['merchant:order:quick-create', 'merchant:order:create'],
|
||||
'order:first-leg:edit': ['merchant:order:edit'],
|
||||
|
||||
@@ -42,7 +42,7 @@ test('keeps account and notification routes as fixed-header utilities', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('organises customer-relevant business pages under five task-oriented sidebar entries', () => {
|
||||
test('keeps standalone business pages at the client sidebar root', () => {
|
||||
assert.deepEqual(
|
||||
clientNavigation.filter((node) => !node.hidden).map((node) => ({
|
||||
title: node.title,
|
||||
@@ -54,6 +54,8 @@ test('organises customer-relevant business pages under five task-oriented sideba
|
||||
title: '订单中心',
|
||||
children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'],
|
||||
},
|
||||
{ title: '报关资料', children: undefined },
|
||||
{ title: '船期价格', children: undefined },
|
||||
{
|
||||
title: '海外仓',
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'],
|
||||
@@ -89,6 +91,8 @@ test('flattens visible leaves with unique paths', () => {
|
||||
'/cross-border/procurement',
|
||||
'/cross-border/domestic-trucking',
|
||||
'/cross-border/pallet-shipping',
|
||||
'/first-leg/customs-documents',
|
||||
'/first-leg/sailing-prices',
|
||||
'/overseas-warehouse/labeling',
|
||||
'/overseas-warehouse/storage-transfer',
|
||||
'/overseas-warehouse/cargo-disposal',
|
||||
@@ -107,10 +111,10 @@ test('flattens visible leaves with unique paths', () => {
|
||||
})
|
||||
|
||||
test('filters inaccessible leaves and empty navigation groups', () => {
|
||||
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list'])
|
||||
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list', 'order:first-leg:customs-list', 'order:first-leg:pricing-list'])
|
||||
|
||||
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg'])
|
||||
assert.deepEqual(visible.map((item) => item.title), ['订单中心'])
|
||||
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg', '/first-leg/customs-documents', '/first-leg/sailing-prices'])
|
||||
assert.deepEqual(visible.map((item) => item.title), ['订单中心', '报关资料', '船期价格'])
|
||||
assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile')
|
||||
assert.equal(getFirstAccessiblePath([]), '/forbidden')
|
||||
})
|
||||
|
||||
@@ -57,6 +57,8 @@ export const clientNavigation: ClientNavigationNode[] = [
|
||||
requireLeaf('cross-border-pallet-shipping'),
|
||||
],
|
||||
},
|
||||
requireLeaf('first-leg-customs-documents', '报关资料'),
|
||||
requireLeaf('first-leg-sailing-prices', '船期价格'),
|
||||
{
|
||||
key: 'overseas-warehouse-center',
|
||||
title: '海外仓',
|
||||
|
||||
Reference in New Issue
Block a user