优化
This commit is contained in:
+23
-1
@@ -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 }
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user