Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
4 changed files with 188 additions and 27 deletions
Showing only changes of commit aee562d998 - Show all commits
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { completeAllBuyContainerCustoms, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
@@ -40,4 +40,19 @@ describe('platform container customs API', () => {
expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion)
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
})
it('keeps source and completion downloads isolated on the long document id', async () => {
const source = new Blob(['source'])
const completion = new Blob(['completion'])
const get = vi.spyOn(http, 'get').mockResolvedValueOnce(source).mockResolvedValueOnce(completion)
const documentId = '2092438843630391407'
await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source)
await expect(downloadContainerCustomsCompletion(documentId)).resolves.toBe(completion)
expect(get.mock.calls).toEqual([
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
])
})
})
@@ -78,6 +78,14 @@ export async function getContainerCustoms(loadingPlanId: string) {
return normalize(unwrapApiResponse(response))
}
export function downloadContainerCustomsDocument(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
}
export function downloadContainerCustomsCompletion(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' })
}
async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) {
const response = await http.post<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
@@ -1,32 +1,134 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import type { ContainerCustomsStatus, ContainerCustomsView } from '../api/platformContainerCustoms'
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerCustomsPanel.vue'), 'utf8')
const api = vi.hoisted(() => ({
get: vi.fn(),
start: vi.fn(),
completeAllBuy: vi.fn(),
upload: vi.fn(),
downloadSource: vi.fn(),
downloadCompletion: vi.fn(),
}))
describe('ContainerCustomsPanel contract', () => {
it('renders compact coverage, missing inbound and frozen document facts with operation first', () => {
expect(source).toContain('退税总票')
expect(source).toContain('缺资料清单')
expect(source).toContain('<th>操作</th><th>关联入仓码</th><th>版本</th><th>客户</th>')
expect(source).toContain("item.taxMode === 'TAX_REFUND' && !item.covered")
vi.mock('../api/platformContainerCustoms', () => ({
getContainerCustoms: api.get,
startContainerCustoms: api.start,
completeAllBuyContainerCustoms: api.completeAllBuy,
uploadContainerCustomsCompletion: api.upload,
downloadContainerCustomsDocument: api.downloadSource,
downloadContainerCustomsCompletion: api.downloadCompletion,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
import ContainerCustomsPanel from './ContainerCustomsPanel.vue'
function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView {
return {
aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403',
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1,
inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{
documentId: '2092438843630391407', tenantId: '2092438843630391406',
source: { fileId: '2092438843630391408', fileName: '客户退税资料.pdf', fileHash: 'source-hash', sizeBytes: 12, version: 2, uploadedAt: '2026-08-29T10:00:00' },
inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true,
completion: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关完成文件.pdf', fileHash: 'completion-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
}],
startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null,
completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null,
}
}
async function render(status: ContainerCustomsStatus, canManage = true, hasCompletion = false) {
api.get.mockResolvedValue(customs(status, hasCompletion))
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
return wrapper
}
function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
return wrapper.findAll('button').map((button) => button.text())
}
describe('ContainerCustomsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
api.downloadSource.mockResolvedValue(new Blob(['source']))
api.downloadCompletion.mockResolvedValue(new Blob(['completion']))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:customs-file') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
it('gates start by server status and confirms frozen facts', () => {
expect(source).toContain("customs.status === 'READY_TO_START'")
expect(source).toContain('冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实')
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.find('[aria-label="下载源资料 客户退税资料.pdf"]').exists()).toBe(true)
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('requires a second confirmation for the final missing completion and keeps departure independent', () => {
expect(source).toContain('missingCompletionCount.value === 1')
expect(source).toContain('确认上传并完成')
expect(source).toContain("departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件'")
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('上传完成文件')
expect(wrapper.text()).toContain('提交')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('覆盖完成文件')
expect(wrapper.text()).toContain('报关完成文件.pdf')
wrapper.unmount()
wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读')
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('creates the all-buy XLSX task with loadingPlanId as its only parameter', () => {
expect(source).toContain("exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX'")
expect(source).toContain('parameters: { loadingPlanId: String(props.loadingPlanId) }')
expect(source).not.toContain('fileType')
expect(source).not.toContain('documentType')
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关完成文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('routes source and completion downloads independently by the long document id and original names', async () => {
const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadCompletion).not.toHaveBeenCalled()
await wrapper.get('[aria-label="下载完成文件 报关完成文件.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:customs-file')
})
it('preserves backend error code, request id and trace id when one download fails', async () => {
api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download'))
const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('文件不可用')
expect(wrapper.text()).toContain('CUSTOMS_FILE_404')
expect(wrapper.text()).toContain('request-customs-download')
expect(wrapper.text()).toContain('trace-customs-download')
expect(api.downloadCompletion).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { FileUp } from '@lucide/vue'
import { Download, FileUp } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { createPlatformExportTask } from '@/modules/downloads'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -9,6 +9,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
downloadContainerCustomsCompletion,
downloadContainerCustomsDocument,
getContainerCustoms,
startContainerCustoms,
uploadContainerCustomsCompletion,
@@ -30,6 +32,7 @@ const loading = ref(false)
const actionLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const downloadingKey = ref('')
const completionFiles = reactive<Record<string, File | null>>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
@@ -44,6 +47,7 @@ function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
function selectCompletion(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
completionFiles[documentId] = upload.raw || null
actionError.value = null
}
@@ -69,6 +73,7 @@ async function start() {
}
async function uploadCompletion(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
const selected = completionFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应完成文件'); return }
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
@@ -88,6 +93,34 @@ async function uploadCompletion(document: ContainerCustomsDocument) {
finally { actionLoading.value = false }
}
function saveBlob(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: ContainerCustomsDocument, kind: 'source' | 'completion') {
const target = kind === 'source' ? document.source : document.completion
if (!target) return
const key = `${kind}:${document.documentId}`
downloadingKey.value = key
actionError.value = null
try {
const blob = kind === 'source'
? await downloadContainerCustomsDocument(document.documentId)
: await downloadContainerCustomsCompletion(document.documentId)
saveBlob(blob, target.fileName)
ElMessage.success(`${kind === 'source' ? '源资料' : '完成文件'}下载已开始`)
} catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '源资料' : '完成文件'}下载失败`)
} finally {
downloadingKey.value = ''
}
}
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { type: 'warning', confirmButtonText: '确认完成' }) }
@@ -129,6 +162,7 @@ onMounted(load)
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
</div>
</header>
<div v-if="actionError" class="action-error" role="alert"><span>{{ actionError.message }}</span><ApiErrorMeta :details="actionError" /></div>
<LoadErrorState v-if="loadError" compact title="整柜报关加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else-if="loading"><el-skeleton :rows="3" animated /></div>
<template v-else-if="customs">
@@ -139,9 +173,11 @@ onMounted(load)
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<h3>冻结资料与完成文件</h3>
<table class="compact-table document-table"><thead><tr><th>操作</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
<table class="compact-table document-table"><thead><tr><th>操作</th><th>源资料</th><th>完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖完成文件' : '上传完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></td>
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖完成文件' : '上传完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载源资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')"><Download :size="14" />下载</el-button></div></td>
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')"><Download :size="14" />下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>资料 v{{ document.source.version }}<span v-if="document.completion"> / 完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传完成文件' : '待上传' }}</td>
</tr>
</tbody></table>
@@ -152,5 +188,5 @@ onMounted(load)
</template>
<style scoped>
.container-customs{padding:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:230px}.document-table td:first-child{display:flex;gap:6px}.departure-state{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px}
.container-customs{padding:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.action-error{margin-top:8px;padding:8px 10px;border:1px solid var(--el-color-danger-light-5);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:12px}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:230px}.document-actions,.file-cell{display:flex;align-items:center;gap:6px}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px}
</style>