This commit is contained in:
2026-08-28 16:54:16 +08:00
parent 22f0528593
commit f28b7f178b
7 changed files with 679 additions and 60 deletions
+23 -1
View File
@@ -18,7 +18,7 @@ interface RetryableRequest extends InternalAxiosRequestConfig {
function unwrap<T>(envelope: ApiEnvelope<T>) {
if (envelope.code !== '200') {
const fieldErrors = asFieldErrors(envelope.data)
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId)
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
}
return envelope.data
}
@@ -36,6 +36,7 @@ function mapError(error: unknown) {
error.response?.status,
fieldErrors,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
}
if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR')
@@ -142,6 +143,7 @@ export async function apiBlobRequest(config: AxiosRequestConfig) {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
return response.data
} catch (error) {
await throwBlobApiError(error)
throw mapError(error)
}
}
@@ -151,8 +153,28 @@ export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
return { blob: response.data, headers: response.headers }
} catch (error) {
await throwBlobApiError(error)
throw mapError(error)
}
}
async function throwBlobApiError(error: unknown) {
if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return
try {
const body = JSON.parse(await error.response.data.text()) as unknown
if (!isApiEnvelope(body)) return
const fieldErrors = asFieldErrors(body.data)
throw new ApiError(
apiErrorMessage(body.message, fieldErrors),
body.code,
error.response.status,
fieldErrors,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
} catch (parsedError) {
if (parsedError instanceof ApiError) throw parsedError
}
}
export { ApiError }
+1
View File
@@ -18,6 +18,7 @@ export class ApiError extends Error {
readonly status?: number,
readonly fieldErrors?: ApiFieldErrors,
readonly traceId?: string | null,
readonly requestId?: string | null,
) {
super(message)
this.name = 'ApiError'
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./customs.ts', import.meta.url), 'utf8')
const httpTypes = readFileSync(new URL('../../../api/types.ts', import.meta.url), 'utf8')
const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8')
test('maps only the frozen merchant container-customs contract', () => {
assert.match(source, /type MerchantCustomsStatus = 'WAITING_UPLOAD' \| 'DOCUMENT_UPLOADED' \| 'DECLARING' \| 'CUSTOMS_COMPLETED'/)
assert.match(source, /inboundId: string/)
assert.match(source, /orderId: string/)
assert.match(source, /documentId: string/)
assert.match(source, /loadingPlanId: string/)
assert.match(source, /url: `\/merchant\/v1\/customs\/orders\/\$\{encodedId\(orderId\)\}`/)
assert.doesNotMatch(source, /customs\/cases|MerchantCustomsCaseDetail|getMerchantCustomsByOrder/)
})
test('sends multi-inbound scope, optimistic versions and idempotency for every write', () => {
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.match(source, /data\.append\('file', file\)/)
assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/)
})
test('preserves request and trace identifiers on API errors', () => {
assert.match(httpTypes, /readonly requestId\?: string \| null/)
assert.match(httpSource, /envelope\.traceId, envelope\.requestId/)
assert.match(source, /interface ApiErrorMeta/)
assert.match(source, /requestId: error\.requestId \?\? null/)
assert.match(source, /traceId: error\.traceId \?\? null/)
})
+198 -19
View File
@@ -1,31 +1,210 @@
import { ApiError, apiRequest } from '@/api/http'
import { ApiError, apiBlobResponseRequest, apiRequest } from '@/api/http'
import { createIdempotencyKey } from '@/modules/first-leg/api/firstLeg'
export interface MerchantCustomsReceipt {
id: number | string
receiptType: 'RECEIVED' | 'DECLARED' | 'REJECTED' | 'INSPECTION' | 'RELEASED' | 'OTHER' | string
receiptVersion: number | null
fileName: string | null
uploadedAt: string | null
export type MerchantCustomsStatus = 'WAITING_UPLOAD' | 'DOCUMENT_UPLOADED' | 'DECLARING' | 'CUSTOMS_COMPLETED'
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
export interface MerchantCustomsFile {
fileId: string
fileName: string
fileHash: string
contentType: string | null
sizeBytes: number
version: number
uploadedAt: string
}
export interface MerchantCustomsCaseDetail {
customsCase: {
id: number | string
customsNo: string | null
orderId: number | string
status: string | null
updatedAt: string | null
}
receipts: MerchantCustomsReceipt[]
export interface MerchantCustomsInbound {
inboundId: string
orderId: string
orderNo: string
inboundCode: string
tenantId: string
customerName: string
taxMode: 'TAX_REFUND' | 'BUY_DECLARATION'
covered: boolean
}
export function getMerchantCustomsByOrder(orderId: number | string) {
return apiRequest<MerchantCustomsCaseDetail>({
export interface MerchantCustomsDocument {
documentId: string
tenantId: string
source: MerchantCustomsFile
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
completion: MerchantCustomsFile | null
}
export interface MerchantCustomsContainer {
aggregateId: string
loadingPlanId: string
containerBookingId: string
planNo?: string
cabinetNo?: string
containerNo: string | null
status: ContainerCustomsStatus
merchantStatus?: MerchantCustomsStatus | null
version: number
taxRefundCount: number
coveredTaxRefundCount: number
inbounds: MerchantCustomsInbound[]
documents: MerchantCustomsDocument[]
startedAt?: string | null
completedAt?: string | null
}
export interface ApiErrorMeta {
code: string
requestId: string | null
traceId: string | null
}
export interface MerchantCustomsDownload {
blob: Blob
fileName: string
}
export const MERCHANT_CUSTOMS_STATUS_LABELS: Record<MerchantCustomsStatus, string> = {
WAITING_UPLOAD: '待上传资料',
DOCUMENT_UPLOADED: '资料已上传',
DECLARING: '报关中',
CUSTOMS_COMPLETED: '报关完成',
}
const MAX_CUSTOMS_FILE_SIZE = 50 * 1024 * 1024
function encodedId(value: number | string) {
return encodeURIComponent(String(value))
}
function inboundQuery(inboundIds: Array<number | string>) {
const params = new URLSearchParams()
inboundIds.map(String).forEach((id) => params.append('inboundIds', id))
return params.toString()
}
function fileBody(file: File) {
const data = new FormData()
data.append('file', file)
return data
}
export function validateMerchantCustomsFile(file: File) {
if (file.size > MAX_CUSTOMS_FILE_SIZE) throw new Error('单个文件不能超过 50MB')
}
export function getMerchantCustomsForOrder(orderId: number | string) {
return apiRequest<MerchantCustomsContainer>({
method: 'GET',
url: `/merchant/v1/customs/orders/${orderId}`,
url: `/merchant/v1/customs/orders/${encodedId(orderId)}`,
})
}
export function uploadMerchantCustomsDocument(planId: number | string, inboundIds: Array<number | string>, file: File) {
validateMerchantCustomsFile(file)
return apiRequest<MerchantCustomsDocument>({
method: 'POST',
url: `/merchant/v1/customs/loading-plans/${encodedId(planId)}/documents/upload?${inboundQuery(inboundIds)}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-upload-${planId}`) },
data: fileBody(file),
timeout: 120_000,
})
}
export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: Array<number | string>, file: File) {
validateMerchantCustomsFile(file)
const params = new URLSearchParams(inboundQuery(inboundIds))
params.set('expectedVersion', String(document.source.version))
return apiRequest<MerchantCustomsDocument>({
method: 'POST',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/upload?${params.toString()}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-replace-${document.documentId}`) },
data: fileBody(file),
timeout: 120_000,
})
}
export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: Array<number | string>) {
return apiRequest<MerchantCustomsDocument>({
method: 'PUT',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) },
data: { inboundIds: inboundIds.map(String), expectedVersion: document.source.version },
})
}
export function deleteMerchantCustomsDocument(document: MerchantCustomsDocument) {
return apiRequest<MerchantCustomsContainer>({
method: 'DELETE',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-delete-${document.documentId}`) },
params: { expectedVersion: document.source.version },
})
}
function contentDispositionFileName(value: unknown, fallback: string) {
if (typeof value !== 'string') return fallback
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (encoded) {
try { return decodeURIComponent(encoded) } catch { return fallback }
}
return value.match(/filename="?([^";]+)"?/i)?.[1] || fallback
}
async function requireBinaryDownload(blob: Blob) {
if (!blob.type.toLowerCase().includes('json')) return
try {
const body = JSON.parse(await blob.text()) as { code?: unknown; message?: unknown; requestId?: unknown; traceId?: unknown }
if (typeof body.message === 'string') {
throw new ApiError(
body.message,
typeof body.code === 'string' ? body.code : 'DOWNLOAD_FAILED',
undefined,
undefined,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
}
} catch (error) {
if (error instanceof ApiError) throw error
}
}
async function download(url: string, fallbackName: string): Promise<MerchantCustomsDownload> {
const response = await apiBlobResponseRequest({ method: 'GET', url })
await requireBinaryDownload(response.blob)
return {
blob: response.blob,
fileName: contentDispositionFileName(response.headers['content-disposition'], fallbackName),
}
}
export function downloadMerchantCustomsSource(document: MerchantCustomsDocument) {
return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName)
}
export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) {
return download(
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`,
document.completion?.fileName || `customs-completion-${document.documentId}`,
)
}
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 }
}
export function merchantCustomsErrorMessage(error: unknown, fallback: string) {
if (!(error instanceof ApiError)) return error instanceof Error ? error.message : fallback
const identifiers = [
error.code ? `错误码 ${error.code}` : '',
error.requestId ? `请求号 ${error.requestId}` : '',
error.traceId ? `追踪号 ${error.traceId}` : '',
].filter(Boolean)
return `${error.message || fallback}${identifiers.length ? `(${identifiers.join(',')})` : ''}`
}
export function isMerchantCustomsUnavailable(error: unknown) {
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
}
@@ -0,0 +1,341 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue'
import {
MERCHANT_CUSTOMS_STATUS_LABELS,
deleteMerchantCustomsDocument,
downloadMerchantCustomsCompletion,
downloadMerchantCustomsSource,
getMerchantCustomsForOrder,
merchantCustomsErrorMessage,
replaceMerchantCustomsDocument,
updateMerchantCustomsDocumentInbounds,
uploadMerchantCustomsDocument,
validateMerchantCustomsFile,
type MerchantCustomsContainer,
type MerchantCustomsDocument,
} from '@/modules/first-leg/api/customs'
const props = defineProps<{
modelValue: boolean
orderId: string
canEdit: boolean
initialDetail?: MerchantCustomsContainer | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: [value: MerchantCustomsContainer]
}>()
const detail = ref<MerchantCustomsContainer | null>(null)
const loading = ref(false)
const errorMessage = ref('')
const selectedInboundIds = ref<string[]>([])
const editingDocument = ref<MerchantCustomsDocument | null>(null)
const editingInboundIds = ref<string[]>([])
const replacingDocument = ref<MerchantCustomsDocument | null>(null)
const uploadInput = ref<HTMLInputElement>()
const replaceInput = ref<HTMLInputElement>()
const mutating = ref(false)
const downloadingKey = ref('')
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')
const merchantStatusLabel = computed(() => detail.value?.merchantStatus
? MERCHANT_CUSTOMS_STATUS_LABELS[detail.value.merchantStatus]
: '待上传资料')
const statusTagType = computed(() => ({
WAITING_UPLOAD: 'warning',
DOCUMENT_UPLOADED: 'success',
DECLARING: 'primary',
CUSTOMS_COMPLETED: 'success',
} as const)[detail.value?.merchantStatus ?? 'WAITING_UPLOAD'])
function initializeSelection(value: MerchantCustomsContainer) {
const currentOrderIds = value.inbounds
.filter((item) => item.taxMode === 'TAX_REFUND' && item.orderId === props.orderId)
.map((item) => item.inboundId)
selectedInboundIds.value = currentOrderIds
}
async function loadDetail() {
if (!props.orderId) return
loading.value = true
errorMessage.value = ''
try {
const result = await getMerchantCustomsForOrder(props.orderId)
detail.value = result
initializeSelection(result)
emit('updated', result)
} catch (error) {
detail.value = null
errorMessage.value = merchantCustomsErrorMessage(error, '退税报关资料加载失败')
} finally {
loading.value = false
}
}
watch(() => props.modelValue, (open) => {
if (!open) return
detail.value = props.initialDetail ?? null
if (detail.value) initializeSelection(detail.value)
void loadDetail()
})
function close() {
emit('update:modelValue', false)
}
function chooseUpload() {
if (!selectedInboundIds.value.length) {
ElMessage.warning('请先选择至少一个退税入仓码')
return
}
uploadInput.value?.click()
}
async function handleUploadFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !detail.value) return
try {
validateMerchantCustomsFile(file)
mutating.value = true
await uploadMerchantCustomsDocument(detail.value.loadingPlanId, selectedInboundIds.value, file)
ElMessage.success('资料上传成功')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '资料上传失败'))
} finally {
mutating.value = false
}
}
function beginEditInbounds(document: MerchantCustomsDocument) {
editingDocument.value = document
editingInboundIds.value = [...document.inboundIds]
}
async function saveInbounds() {
if (!editingDocument.value || !editingInboundIds.value.length) {
ElMessage.warning('请至少保留一个关联入仓码')
return
}
try {
mutating.value = true
await updateMerchantCustomsDocumentInbounds(editingDocument.value, editingInboundIds.value)
editingDocument.value = null
ElMessage.success('关联入仓码已更新')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '关联入仓码更新失败'))
} finally {
mutating.value = false
}
}
async function chooseReplacement(document: MerchantCustomsDocument) {
replacingDocument.value = document
editingInboundIds.value = [...document.inboundIds]
await nextTick()
replaceInput.value?.click()
}
async function handleReplacementFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const document = replacingDocument.value
replacingDocument.value = null
if (!file || !document) return
try {
validateMerchantCustomsFile(file)
mutating.value = true
await replaceMerchantCustomsDocument(document, editingInboundIds.value, file)
ElMessage.success('资料已覆盖并生成新版本')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '资料覆盖失败'))
} finally {
mutating.value = false
}
}
async function removeDocument(document: MerchantCustomsDocument) {
try {
await ElMessageBox.confirm(`确认删除误传资料“${document.source.fileName}”吗?历史版本仍会由系统保留。`, '删除资料', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
mutating.value = true
await deleteMerchantCustomsDocument(document)
ElMessage.success('资料已删除')
await loadDetail()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(merchantCustomsErrorMessage(error, '资料删除失败'))
} finally {
mutating.value = false
}
}
function saveDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'completion') {
const key = `${document.documentId}:${kind}`
downloadingKey.value = key
try {
const result = kind === 'source'
? await downloadMerchantCustomsSource(document)
: await downloadMerchantCustomsCompletion(document)
saveDownload(result.blob, result.fileName)
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
} finally {
if (downloadingKey.value === key) downloadingKey.value = ''
}
}
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 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`
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="customs-documents-dialog"
title="退税报关资料"
width="920px"
append-to-body
destroy-on-close
@close="close"
>
<el-skeleton v-if="loading && !detail" :rows="6" animated />
<div v-else-if="errorMessage" class="customs-error" role="alert">
<strong>退税报关资料加载失败</strong>
<span>{{ errorMessage }}</span>
<el-button :loading="loading" @click="loadDetail">重新加载</el-button>
</div>
<template v-else-if="detail">
<header class="customs-summary">
<div>
<span>柜次</span>
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || detail.loadingPlanId }}</strong>
</div>
<div>
<span>资料覆盖</span>
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</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>
<section class="customs-section">
<div class="customs-section-title">
<div><h3>上传资料</h3><p>支持任意格式,单个文件不超过 50MB;可关联同柜内当前企业的一票或多票退税入仓码。</p></div>
<el-button v-if="canEdit && !locked" type="primary" :loading="mutating" @click="chooseUpload"><Upload :size="16" />选择文件上传</el-button>
</div>
<el-checkbox-group v-model="selectedInboundIds" class="customs-inbound-options" :disabled="!canEdit || locked" aria-label="选择资料关联入仓码">
<el-checkbox v-for="item in taxRefundInbounds" :key="item.inboundId" :value="item.inboundId">
<span class="customs-inbound-code">{{ item.inboundCode }}</span>
<small>{{ item.orderNo }}</small>
</el-checkbox>
</el-checkbox-group>
<input ref="uploadInput" class="customs-hidden-input" type="file" @change="handleUploadFile">
<input ref="replaceInput" class="customs-hidden-input" type="file" @change="handleReplacementFile">
</section>
<section class="customs-section">
<div class="customs-section-title">
<div><h3>资料版本历史</h3><p>每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。</p></div>
</div>
<el-empty v-if="!detail.documents.length" description="尚未上传退税报关资料" :image-size="54" />
<div v-else class="customs-document-list">
<article v-for="item in detail.documents" :key="item.documentId" class="customs-document-card">
<div class="customs-document-main">
<FileText :size="20" />
<div>
<strong>{{ item.source.fileName }}</strong>
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
<small>关联入仓码:{{ item.inboundCodes.join('、') || '-' }}</small>
</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>
<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>
<el-button link type="danger" :disabled="mutating" @click="removeDocument(item)"><Trash2 :size="15" />删除</el-button>
</template>
</div>
<div v-if="editingDocument?.documentId === item.documentId" class="customs-scope-editor">
<el-checkbox-group v-model="editingInboundIds" aria-label="修改资料关联入仓码">
<el-checkbox v-for="inbound in taxRefundInbounds" :key="inbound.inboundId" :value="inbound.inboundId">{{ inbound.inboundCode }}</el-checkbox>
</el-checkbox-group>
<div><el-button @click="editingDocument = null">取消</el-button><el-button type="primary" :loading="mutating" @click="saveInbounds">保存关联</el-button></div>
</div>
</article>
</div>
</section>
</template>
<template #footer><el-button @click="close">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.customs-error { display: grid; gap: 10px; justify-items: start; padding: 22px; border: 1px solid #f1b8b8; background: #fff7f7; color: #9f2d2d; }
.customs-error span { overflow-wrap: anywhere; font-size: 12px; }
.customs-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)) auto; align-items: center; gap: 18px; padding: 16px 18px; border: 1px solid var(--border); background: #f8faf9; }
.customs-summary div { min-width: 0; }
.customs-summary span { display: block; color: var(--muted); font-size: 11px; }
.customs-summary strong { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--navy); font-size: 14px; }
.customs-locked { display: flex; align-items: flex-start; gap: 10px; margin-top: 14px; padding: 13px 15px; border: 1px solid #d5dde1; background: #f5f7f8; color: var(--muted); }
.customs-locked strong, .customs-locked span { display: block; }
.customs-locked strong { color: var(--navy); font-size: 12px; }
.customs-locked span { margin-top: 3px; font-size: 11px; }
.customs-section { margin-top: 18px; }
.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; }
.customs-inbound-options { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 13px 15px; border: 1px solid var(--border); }
.customs-inbound-options :deep(.el-checkbox) { margin-right: 0; }
.customs-inbound-code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.customs-inbound-options small { margin-left: 7px; color: var(--muted); }
.customs-hidden-input { display: none; }
.customs-document-list { display: grid; gap: 10px; }
.customs-document-card { padding: 14px 16px; border: 1px solid var(--border); }
.customs-document-main { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
.customs-document-main > svg { flex: 0 0 20px; color: var(--primary); }
.customs-document-main > div { min-width: 0; }
.customs-document-main strong, .customs-document-main span, .customs-document-main small { display: block; overflow-wrap: anywhere; }
.customs-document-main strong { color: var(--navy); font-size: 13px; }
.customs-document-main span, .customs-document-main small { margin-top: 4px; color: var(--muted); font-size: 11px; }
.customs-document-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px; margin-top: 8px; }
.customs-scope-editor { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); }
.customs-scope-editor :deep(.el-checkbox) { margin-right: 16px; }
</style>
@@ -6,6 +6,8 @@ const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url)
const routerSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8')
const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8')
const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8')
test('routes the confirmed first-leg path to the implemented order view', () => {
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
@@ -198,7 +200,7 @@ test('shows one unified progress line and C02 business summaries without detail
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /国内装柜进度及后续履约同步/)
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
assert.match(source, /报关回执/)
assert.doesNotMatch(source, /<h3>报关回执<\/h3>/)
assert.match(source, /船期摘要/)
assert.match(source, /柜次进度/)
assert.doesNotMatch(source, /summary\.sealNo \|\|/)
@@ -492,6 +494,34 @@ test('copies reactive order drafts into plain request data', () => {
assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/)
})
test('opens customer customs documents only for server-confirmed loaded tax-refund orders', () => {
assert.match(source, /order\.status !== 'LOADED'/)
assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/)
assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/)
assert.match(source, /v-if="eligibleCustomsDetail\(row as FirstLegOrder\)"[\s\S]*?报关资料/)
assert.match(source, /<CustomsDocumentsDialog[\s\S]*?:can-edit="auth\.hasPermission\('order:first-leg:edit'\)"/)
assert.doesNotMatch(customsApiSource, /customs\/cases\//)
})
test('supports the frozen customs document workflow and customer-safe downloads', () => {
for (const path of [
'/merchant/v1/customs/orders/',
'/merchant/v1/customs/loading-plans/',
'/merchant/v1/customs/documents/',
'/inbounds',
'/completion/download',
]) assert.match(customsApiSource, new RegExp(path.replaceAll('/', '\\/')))
assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/)
assert.match(customsApiSource, /inboundIds: inboundIds\.map\(String\)/)
assert.match(customsApiSource, /requestId: error\.requestId/)
assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/)
assert.match(customsDialogSource, /detail\.status === 'COMPLETED' && item\.completion/)
assert.match(customsDialogSource, /资料版本历史/)
assert.match(customsDialogSource, /尚未上传退税报关资料/)
assert.match(customsDialogSource, /退税报关资料加载失败/)
assert.doesNotMatch(customsDialogSource, /accept=/)
})
test('uses the global inbound-code policy to require old-system codes during parallel running', () => {
assert.match(source, /getMerchantInboundCodePolicy\(\)/)
assert.match(source, /async function validateEditorForm\(\)[\s\S]*?const policy = await getMerchantInboundCodePolicy\(\)/)
@@ -2,7 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus'
import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue'
import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, FileText, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES, type FirstLegOrderStatus } from '@chongchong/shared'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
@@ -10,8 +10,9 @@ import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import { getMerchantCustomsByOrder, isMerchantCustomsUnavailable, type MerchantCustomsCaseDetail } from '@/modules/first-leg/api/customs'
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice,
downloadMerchantOrderCartonLabelsBatch,
@@ -95,7 +96,7 @@ const cargoTemplateColumns = [
const cargoNumberKeys = new Set<string>(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm'])
const fixedOrderColumns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true },
{ key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' },
{ key: 'order', label: '订单号', width: 190, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' },
]
const selectableOrderColumns: DataTableColumn[] = [
@@ -188,9 +189,10 @@ const inboundBillingLoading = ref(false)
const inboundBillingUnavailable = ref(false)
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
const inboundBillingError = ref('')
const customsDetail = ref<MerchantCustomsCaseDetail | null>(null)
const customsLoading = ref(false)
const customsUnavailable = ref(false)
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsEligibilityPending = new Set<string>()
const customsDialogOpen = ref(false)
const customsDialogOrderId = ref('')
const supportingDataLoading = ref(false)
const supportingDataErrors = ref<string[]>([])
const destinationWarehousesLoading = ref(false)
@@ -461,6 +463,41 @@ const pagedOrders = computed(() => {
return filteredOrders.value.slice(start, start + tablePageSize.value)
})
function eligibleCustomsDetail(order: FirstLegOrder) {
const value = customsByOrder[order.id]
if (order.status !== 'LOADED' || !value?.merchantStatus) return null
return value.inbounds.some((item) => item.orderId === order.id && item.taxMode === 'TAX_REFUND') ? value : null
}
async function loadCustomsEligibility(order: FirstLegOrder) {
if (order.status !== 'LOADED' || customsByOrder[order.id] !== undefined || customsEligibilityPending.has(order.id)) return
customsEligibilityPending.add(order.id)
try {
customsByOrder[order.id] = await getMerchantCustomsForOrder(order.id)
} catch (error) {
customsByOrder[order.id] = null
if (!isMerchantCustomsUnavailable(error)) {
// Keep the operation hidden when eligibility cannot be proven by the customer-scoped server response.
}
} finally {
customsEligibilityPending.delete(order.id)
}
}
watch(() => pagedOrders.value.map((order) => `${order.id}:${order.status}`).join('|'), () => {
pagedOrders.value.forEach((order) => void loadCustomsEligibility(order))
}, { immediate: true })
function openCustomsDocuments(order: FirstLegOrder) {
if (!eligibleCustomsDetail(order)) return
customsDialogOrderId.value = order.id
customsDialogOpen.value = true
}
function updateCustomsDetail(value: MerchantCustomsContainer) {
if (customsDialogOrderId.value) customsByOrder[customsDialogOrderId.value] = value
}
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
const pagedDetailCargoItems = computed(() => {
@@ -486,7 +523,6 @@ const progressSummaryItems = computed(() => {
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
{ label: '拣货进度', value: pickingProgress },
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '待更新' },
{ label: '报关回执', value: customsDetail.value?.receipts.length ? `${customsDetail.value.receipts.length} 份回执` : customsUnavailable.value ? '暂无回执' : '查询中' },
{ label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
@@ -598,9 +634,6 @@ watch(isCreatePage, (isCreate, wasCreate) => {
watch(detailOpen, (open) => {
if (!open) {
resetInboundBillingState()
customsDetail.value = null
customsLoading.value = false
customsUnavailable.value = false
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
}
})
@@ -1073,7 +1106,6 @@ async function openDetail(order: FirstLegOrder) {
detailLoading.value = true
detailError.value = ''
void loadInboundBilling(order)
void loadCustoms(order)
try {
const detailedOrder = await store.loadDetail(order.id)
// Load previews from the persisted file IDs after the store has replaced the detail row.
@@ -1138,21 +1170,6 @@ async function loadInboundBilling(order: FirstLegOrder) {
}
}
async function loadCustoms(order: FirstLegOrder) {
customsDetail.value = null
customsUnavailable.value = false
if (order.status === 'DRAFT') return
customsLoading.value = true
try {
customsDetail.value = await getMerchantCustomsByOrder(order.id)
} catch (error) {
if (isMerchantCustomsUnavailable(error)) customsUnavailable.value = true
else ElMessage.warning(error instanceof Error ? error.message : '报关回执加载失败')
} finally {
customsLoading.value = false
}
}
async function retryInboundBilling() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await loadInboundBilling(order)
@@ -1632,6 +1649,7 @@ async function searchOrders(model: QueryModel) {
<div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
</div>
</template>
<template #order="{ row }">
@@ -1892,16 +1910,6 @@ async function searchOrders(model: QueryModel) {
</div>
<el-empty v-else description="暂无柜次分配信息" :image-size="50" />
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>报关回执</h3>
<el-skeleton v-if="customsLoading" :rows="2" animated />
<el-empty v-else-if="customsUnavailable || !customsDetail?.receipts.length" description="暂无客户可见报关回执" :image-size="50" />
<div v-else class="customs-receipt-list">
<article v-for="receipt in customsDetail.receipts" :key="receipt.id">
<strong>{{ receipt.receiptType }}</strong><span>版本 {{ receipt.receiptVersion ?? '-' }}</span><span>{{ receipt.uploadedAt ? formatDateTime(receipt.uploadedAt) : '时间待更新' }}</span>
</article>
</div>
</section>
<section class="detail-section">
<h3>履约快递信息</h3>
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
@@ -1964,6 +1972,13 @@ async function searchOrders(model: QueryModel) {
</div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
</el-dialog>
<CustomsDocumentsDialog
v-model="customsDialogOpen"
:order-id="customsDialogOrderId"
:can-edit="auth.hasPermission('order:first-leg:edit')"
:initial-detail="customsByOrder[customsDialogOrderId]"
@updated="updateCustomsDetail"
/>
</section>
</template>
@@ -2226,9 +2241,6 @@ async function searchOrders(model: QueryModel) {
.cabinet-progress-card dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 13px 0 0; }
.cabinet-progress-card dt { color: var(--muted); font-size: 10px; }
.cabinet-progress-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; }
.customs-receipt-list { display: grid; gap: 8px; }
.customs-receipt-list article { display: flex; flex-wrap: wrap; gap: 10px; padding: 11px 13px; border: 1px solid var(--border); color: var(--muted); font-size: 11px; }
.customs-receipt-list strong { color: var(--navy); }
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-information span { color: var(--muted); font-size: 11px; }