This commit is contained in:
2026-10-30 11:20:03 +08:00
parent bda33648f3
commit d6ef770e48
11 changed files with 355 additions and 109 deletions
+1 -1
View File
@@ -6,5 +6,5 @@
// biome-ignore lint: disable
export {}
declare global {
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
}
@@ -35,3 +35,16 @@
2026-10-29 用户截图调整:采购待确认支持确认或必填原因驳回;四张实收/库存数量卡片置顶,商品及收件信息置底。本土卡车数量改托数,托数及预算须为正数。采购及卡车先等待Finance余额/授信冻结成功,再以同一幂等键建单;不足保留表单并提示原因。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
## 海外采购下单与结果处理(2026-10-30)
依据:当前对话的下单页与详情页截图,以及用户明确要求“商品链接下单必填”“下单成功返回列表”“驳回/确认移至列表,弹窗显示照片和采购数量,驳回必填理由”“确认后两个按钮隐藏”。
| 页面 | 操作及承载 | 调整 | 验收 |
|---|---|---|---|
| 海外采购下单 | 既有独立页面 | 商品链接标记必填并校验非空及有效 HTTP/HTTPS 地址;成功返回采购列表,失败保留表单 | 定向自动测试;待人工截图确认 |
| 海外采购列表 | 待客户确认行的操作列 | 提供详情、驳回、确认采购结果;确认/驳回成功刷新列表,离开待客户确认状态后隐藏两项动作 | 状态与权限自动测试;待人工截图确认 |
| 采购结果确认/驳回 | 居中弹窗 | 显示单号、商品、采购数量、累计收货、逐批数量与可放大的收货照片;驳回原因必填,最多1000字;失败保留内容供重试 | 定向自动测试;待人工1920×1080、100%截图确认 |
| 海外采购详情 | 既有独立页面 | 移除结果确认与驳回按钮,保留详情和刷新 | 源码合同核对;待人工截图确认 |
2026-10-30 照片布局按用户截图反馈收紧:收货批次按横向三列排列,照片缩略图为112×84,同批照片横向排列,超出可横向滚动;保留批次数量、时间和点击放大预览。待人工截图确认。
@@ -12,6 +12,7 @@ export interface ProcurementSummary {
budgetUnitPrice: Amount | null
currency: string | null
status: string
reviewRemark?: string | null
version: number
createdAt: string
updatedAt: string
@@ -25,6 +26,7 @@ export interface ProcurementOrder extends ProcurementSummary {
plannedWarehouseName: string | null
actualWarehouseCode: string | null
actualWarehouseName: string | null
remark: string | null
}
export interface ProcurementFee {
feeNo: string
@@ -75,7 +75,9 @@ test('covers list detail create loading error empty pagination and actions-first
assert.match(view, /<DataTable[^>]*:row-actions="getRowActions"/)
assert.match(view, /key: 'actions', label: '操作', width: 90, fixed: 'left'/)
assert.match(view, /class="business-table-card"/)
assert.doesNotMatch(procurementView, /inventoryQuantity|outboundQuantity|reviewRemark/)
assert.doesNotMatch(procurementView, /inventoryQuantity|outboundQuantity/)
assert.match(procurementView, /label: '拒绝原因'/)
assert.match(procurementView, /reviewRemark/)
assert.match(procurementView, /procurementStatuses/)
assert.match(procurementView, /ProcurementDetailPanel/)
})
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { ElMessage } from 'element-plus'
import { accessTokenIdentity } from '@/api/session'
import { useAuthStore } from '@/modules/auth/public'
import { confirmProcurement, rejectProcurement, getProcurementWorkflow, getProcurementFile, type ProcurementSummary, type ProcurementWorkflow } from '../api/procurement'
import { procurementRequestKey } from '../procurementSubmission'
import { dateTime } from '../procurementPresentation'
const props = defineProps<{ order: ProcurementSummary; decision: 'confirm' | 'reject' }>()
const emit = defineEmits<{ close: []; changed: [] }>()
const auth = useAuthStore()
const owner = auth.token ? accessTokenIdentity(auth.token) : null
const current = () => active && !!owner && owner === (auth.token ? accessTokenIdentity(auth.token) : null)
const workflow = shallowRef<ProcurementWorkflow | null>(null)
const loading = ref(false), saving = ref(false), error = ref(''), photoError = ref(''), reason = ref('')
const photos = ref<Record<string, string>>({})
const isReject = computed(() => props.decision === 'reject')
const canSubmit = computed(() => !loading.value && workflow.value?.status === 'WAIT_CUSTOMER_CONFIRM' && auth.hasPermission('merchant:cross-border:procurement:create'))
let active = true, generation = 0, attempt: { fingerprint: string; key: string } | null = null
function clearPhotos() { Object.values(photos.value).forEach(url => URL.revokeObjectURL(url)); photos.value = {} }
async function load() {
if (loading.value || saving.value || !current()) return
const sequence = ++generation
loading.value = true; error.value = ''; photoError.value = ''; workflow.value = null; clearPhotos()
try {
const result = await getProcurementWorkflow(props.order.orderNo)
if (!current() || sequence !== generation) return
workflow.value = result
if (result.status !== 'WAIT_CUSTOMER_CONFIRM') { error.value = '订单状态已更新,当前不能确认或驳回,请返回列表刷新'; return }
const ids = [...new Set(result.receipts.flatMap(receipt => receipt.fileIds))]
await Promise.all(ids.map(async id => {
try {
const blob = await getProcurementFile(props.order.orderNo, id)
if (!current() || sequence !== generation) return
if (!/^image\/(png|jpeg|gif|webp|bmp)$/i.test(blob.type)) throw new Error('附件不是可预览的收货照片')
photos.value[id] = URL.createObjectURL(blob)
} catch { if (current() && sequence === generation) photoError.value = '部分收货照片加载失败,请重新加载后核对' }
}))
} catch (cause) { if (current() && sequence === generation) error.value = cause instanceof Error ? cause.message : '采购结果加载失败' }
finally { if (current() && sequence === generation) loading.value = false }
}
function fileName(id: string) { return workflow.value?.files?.find(file => file.fileId === id)?.fileName || '收货照片' }
async function submitDecision() {
if (saving.value || !current() || !canSubmit.value || !!photoError.value || !workflow.value) return
if (isReject.value && (!reason.value.trim() || reason.value.trim().length > 1000)) { error.value = '请填写驳回原因,最多1000字'; return }
const version = workflow.value.version, orderNo = props.order.orderNo
const fingerprint = JSON.stringify([owner, orderNo, version, props.decision, isReject.value ? reason.value.trim() : ''])
if (attempt?.fingerprint !== fingerprint) attempt = { fingerprint, key: procurementRequestKey() }
saving.value = true; error.value = ''
try {
const data = { version, idempotencyKey: attempt.key }
if (isReject.value) await rejectProcurement(orderNo, { ...data, reason: reason.value.trim() })
else await confirmProcurement(orderNo, data)
if (!current()) return
ElMessage.success(isReject.value ? '已驳回,等待平台重新上传照片' : '采购结果已确认')
emit('changed')
} catch (cause) { if (current()) error.value = cause instanceof Error ? cause.message : '操作失败,请重试' }
finally { saving.value = false }
}
onBeforeUnmount(() => { active = false; generation++; clearPhotos() })
void load()
</script>
<template>
<el-dialog :model-value="true" :title="isReject ? '驳回采购结果' : '确认采购结果'" width="800px" append-to-body :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving" @close="!saving && emit('close')">
<div v-loading="loading" class="decision-content">
<dl class="decision-quantities"><div><dt>采购单号</dt><dd>{{ order.orderNo }}</dd></div><div><dt>商品名称</dt><dd>{{ order.productName }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }} 件</dd></div><div><dt>累计收货数量</dt><dd>{{ workflow?.receivedQuantity ?? '—' }} 件</dd></div></dl>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-alert v-if="photoError" :title="photoError" type="error" :closable="false" />
<el-button v-if="error || photoError" :disabled="saving || loading" @click="load">重新加载</el-button>
<div class="decision-batches">
<section v-for="(receipt, index) in workflow?.receipts || []" :key="receipt.id" class="decision-batch">
<h3>第 {{ index + 1 }} 批:{{ receipt.quantity }} 件 <small>{{ dateTime(receipt.receivedAt) }}</small></h3>
<div class="decision-photos"><el-image v-for="id in receipt.fileIds" :key="id" :src="photos[id]" :alt="fileName(id)" :preview-src-list="Object.values(photos)" :initial-index="Object.keys(photos).indexOf(id)" fit="contain" preview-teleported><template #error><span>{{ fileName(id) }}:{{ loading ? '加载中' : '照片未加载' }}</span></template></el-image></div>
</section>
</div>
<p v-if="workflow && !workflow.receipts.length">暂无收货照片</p>
<el-form v-if="isReject" label-position="top" :disabled="saving"><el-form-item label="驳回原因" required><el-input v-model="reason" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请填写采购结果不符合要求的原因" /></el-form-item></el-form>
<p v-else>请核对采购数量和收货照片,确认后平台将安排出库。</p>
</div>
<template #footer><el-button :disabled="saving" @click="emit('close')">取消</el-button><el-button :type="isReject ? 'danger' : 'primary'" :loading="saving" :disabled="!canSubmit || !!photoError" @click="submitDecision">{{ isReject ? '提交驳回' : '确认采购结果' }}</el-button></template>
</el-dialog>
</template>
<style scoped>
.decision-content { min-height: 160px; }
.decision-quantities { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0 0 16px; }
.decision-quantities > div { display: flex; gap: 12px; min-width: 0; }
dt { color: var(--muted); } dd { margin: 0; overflow-wrap: anywhere; }
.decision-batches { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 16px 0; }
.decision-batch { min-width: 0; }
h3 { margin: 0 0 8px; color: var(--navy); font-size: 14px; } small { display: block; margin-top: 4px; color: var(--muted); font-weight: 400; }
.decision-photos { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; gap: 8px; overflow-x: auto; padding: 2px 0 6px; }
.decision-photos :deep(.el-image) { flex: 0 0 112px; width: 112px; height: 84px; border: 1px solid var(--border); border-radius: 4px; }
.decision-photos span { padding: 12px; overflow-wrap: anywhere; }
</style>
@@ -78,6 +78,7 @@ const totals = computed(() => feeTotals(props.detail.fees))
<div><dt>收件人</dt><dd>{{ detail.order.contactName }}</dd></div>
<div><dt>联系电话</dt><dd>{{ detail.order.contactPhone }}</dd></div>
<div class="wide"><dt>收货地址</dt><dd>{{ detail.order.receivingAddress }}</dd></div>
<div v-if="detail.order.remark" class="full"><dt>采购备注</dt><dd>{{ detail.order.remark }}</dd></div>
<div v-if="detail.order.productUrl" class="full"><dt>商品链接</dt><dd><a v-if="productHref(detail.order.productUrl)" :href="productHref(detail.order.productUrl)" target="_blank" rel="noopener noreferrer">查看商品</a><span v-else>{{ text(detail.order.productUrl) }}</span></dd></div>
</dl>
</section>
+39 -45
View File
@@ -24,7 +24,7 @@ const submission = moduleFrom(readFileSync(new URL('./procurementSubmission.ts',
})
const normalized = value => JSON.parse(JSON.stringify(value))
const draft = () => ({ ...presentation.emptyProcurementDraft(), purchaseCategory: 'Sample', productName: 'Test product', productSpecification: 'A',
purchaseQuantity: '3', budgetUnitPrice: '0.29', receivingAddress: '测试省测试市测试街 1 号', contactName: '测试收件人', contactPhone: '13800000000' })
productUrl: 'https://example.com/product', purchaseQuantity: '3', budgetUnitPrice: '0.29', receivingAddress: '测试省测试市测试街 1 号', contactName: '测试收件人', contactPhone: '13800000000' })
const fee = (purpose, walletStatus, amount) => ({ purpose, walletStatus, amount, currency: 'CNY' })
test('budget distinguishes unknown from zero, preserves cents, and requires a real telephone/address', () => {
@@ -100,7 +100,7 @@ test('procurement required fields validate individually and blank submissions ne
const calls = []
const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(payload); return { order: { orderNo: 'PO-VALID' } } } } })
try {
const fields = ['procurementDirection', 'productName', 'purchaseQuantity', 'budgetUnitPrice', 'currency', 'contactName', 'contactPhone', 'receivingAddress']
const fields = ['procurementDirection', 'productName', 'productUrl', 'purchaseQuantity', 'budgetUnitPrice', 'currency', 'contactName', 'contactPhone', 'receivingAddress']
const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
for (const field of fields) {
assert.match(source, new RegExp(`<el-form-item[^>]*prop="${field}"`))
@@ -116,6 +116,7 @@ test('procurement required fields validate individually and blank submissions ne
await harness.state.submit()
assert.equal(calls.length, 1)
assert.equal(calls[0].budgetUnitPrice, 0)
assert.deepEqual(normalized(harness.pushes), [{ name: 'cross-border-procurement' }])
} finally { harness.dispose() }
})
@@ -250,44 +251,16 @@ test('domestic purchase creates an export order and displays the actual origin a
})
test('customer confirms with current workflow version and double-click only sends once', async () => {
let resolve, calls = []
const harness = viewHarness({ detailPage: true, api: { confirmProcurement: (orderNo, payload) => {
calls.push({ orderNo, ...payload }); return new Promise(done => { resolve = done })
} } })
test('unchanged create retries reuse the idempotency key', async () => {
const calls = []
const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } })
try {
await flush()
const first = harness.state.confirmReceived(); await flush(); await harness.state.confirmReceived()
assert.equal(calls.length, 1); assert.equal(calls[0].version, 1); assert.equal(calls[0].orderNo, 'PO-1')
assert.ok(calls[0].idempotencyKey)
resolve({ orderNo: 'PO-1', version: 2, status: 'CONFIRMED' }); await first
assert.equal(harness.state.workflow.value.status, 'CONFIRMED'); assert.equal(harness.state.canConfirm.value, false)
Object.assign(harness.state.form, draft()); await harness.state.submit(); await harness.state.submit()
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.equal(harness.pushes.length, 0)
} finally { harness.dispose() }
})
test('failed customer confirmation preserves receipt context for recovery', async () => {
const harness = viewHarness({ detailPage: true, api: { confirmProcurement: async () => { throw new ApiError('版本已更新,请刷新', 'A0409') } } })
try {
await flush(); await harness.state.confirmReceived()
assert.equal(harness.state.workflow.value.status, 'WAIT_CUSTOMER_CONFIRM')
assert.equal(harness.state.confirming.value, false); assert.equal(harness.state.confirmError.value, '版本已更新,请刷新')
} finally { harness.dispose() }
})
test('unknown create and confirmation results reuse idempotency keys for unchanged retries', async () => {
const creates = [], confirms = []
const create = viewHarness({ createPage: true, api: { createProcurement: async payload => { creates.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } })
const confirm = viewHarness({ detailPage: true, api: { confirmProcurement: async (_, payload) => { confirms.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } })
try {
Object.assign(create.state.form, draft()); await create.state.submit(); await create.state.submit()
assert.equal(creates[0].idempotencyKey, creates[1].idempotencyKey)
await flush(); await confirm.state.confirmReceived(); await confirm.state.confirmReceived()
assert.equal(confirms[0].idempotencyKey, confirms[1].idempotencyKey)
} finally { create.dispose(); confirm.dispose() }
})
test('bill-settled manual fees show review results without counting a balance debit', () => {
const bill = determination => ({ ...fee('OTHER', 'NOT_REQUIRED', '100'), determination })
assert.equal(presentation.feeStatus(bill('PENDING')), '账单结算 · 待审核')
@@ -308,15 +281,36 @@ test('insufficient balance and credit blocks procurement creation and preserves
} finally { harness.dispose() }
})
test('customer rejection requires a reason and carries current order version', async () => {
const calls = []
const harness = viewHarness({ detailPage: true, api: { rejectProcurement: async (orderNo, command) => { calls.push({ orderNo, ...command }); return { orderNo, status: 'RECEIVING', version: 2, rejectionReason: command.reason } } } })
test('confirmation and rejection are list actions only while waiting for customer confirmation', async () => {
let status = 'WAIT_CUSTOMER_CONFIRM'
const harness = viewHarness({ api: { listProcurements: async () => ({ records: [{ orderNo: 'PO-1', status, purchaseQuantity: 10 }], total: 1 }) } })
try {
await flush(); await harness.state.rejectReceived()
assert.equal(calls.length, 1); assert.equal(calls[0].orderNo, 'PO-1'); assert.equal(calls[0].version, 1)
assert.equal(calls[0].reason, '照片不清晰'); assert.equal(harness.state.workflow.value.status, 'RECEIVING')
await flush()
const actions = harness.state.rowActions(harness.state.rows.value[0])
assert.deepEqual(normalized(actions.map(action => action.key)), ['detail', 'reject', 'confirm'])
actions[1].run(); assert.equal(harness.state.decision.value, 'reject')
assert.equal(harness.state.decisionTarget.value.orderNo, 'PO-1')
status = 'CONFIRMED'; harness.state.decisionChanged(); await flush()
assert.equal(harness.state.decisionTarget.value, null)
assert.deepEqual(normalized(harness.state.rowActions(harness.state.rows.value[0]).map(action => action.key)), ['detail'])
harness.auth.hasPermission = () => false
assert.equal(harness.state.rowActions({ orderNo: 'PO-1', status: 'WAIT_CUSTOMER_CONFIRM' }).length, 1)
const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
const detailHeader = source.slice(source.indexOf('<header class="procurement-heading">'), source.indexOf('</header>', source.indexOf('<header class="procurement-heading">')))
assert.doesNotMatch(detailHeader, /确认采购结果|驳回/)
} finally { harness.dispose() }
})
test('product links must be nonblank and use HTTP or HTTPS before budget reservation', async () => {
for (const productUrl of ['', ' ', 'javascript:alert(1)', 'invalid', 'https://' ]) {
assert.throws(() => presentation.buildProcurementCreate({ ...draft(), productUrl }, 'Company', 'key'), /商品链接/)
}
let budgets = 0, creates = 0
const harness = viewHarness({ createPage: true, api: { requireOrderBudget: async () => { budgets++ }, createProcurement: async () => { creates++ } } })
try {
for (const productUrl of ['', ' ']) {
Object.assign(harness.state.form, draft(), { productUrl }); await harness.state.submit()
}
assert.equal(budgets, 0); assert.equal(creates, 0); assert.equal(harness.pushes.length, 0)
} finally { harness.dispose() }
const blank = viewHarness({ detailPage: true, api: { rejectionReason: ' ', rejectProcurement: async () => { throw new Error('不得发送空原因') } } })
try { await flush(); await blank.state.rejectReceived(); assert.match(blank.state.confirmError.value, /请填写驳回原因/) }
finally { blank.dispose() }
})
@@ -0,0 +1,138 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import ts from 'typescript'
import * as vue from 'vue'
import { parse, compileScript } from 'vue/compiler-sfc'
const source = readFileSync(new URL('./components/ProcurementDecisionDialog.vue', import.meta.url), 'utf8')
const { descriptor } = parse(source)
const code = ts.transpileModule(compileScript(descriptor, { id: 'decision-test' }).content, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
let key = 0
function harness({ decision = 'confirm', api = {} } = {}) {
const props = vue.reactive({ order: { orderNo: 'PO-1', productName: '商品', purchaseQuantity: 10 }, decision })
const auth = vue.reactive({ token: 'customer-A', hasPermission: () => true })
const calls = [], events = [], disposers = [], created = [], revoked = []
const workflow = { orderNo: 'PO-1', status: 'WAIT_CUSTOMER_CONFIRM', version: 7, receivedQuantity: 8, files: [{ fileId: 'F-1', fileName: 'photo.png' }], receipts: [{ id: 'R-1', quantity: 5, fileIds: ['F-1'], receivedAt: '' }, { id: 'R-2', quantity: 3, fileIds: ['F-2'], receivedAt: '' }] }
const defaults = {
getProcurementWorkflow: async () => workflow,
getProcurementFile: async () => ({ type: 'image/png' }),
confirmProcurement: async (orderNo, payload) => { calls.push({ orderNo, ...payload }); return { ...workflow, status: 'CONFIRMED' } },
rejectProcurement: async (orderNo, payload) => { calls.push({ orderNo, ...payload }); return { ...workflow, status: 'RECEIVING' } },
}
const context = { exports: {}, Error, URL: { createObjectURL: () => { const url = `blob:${created.length}`; created.push(url); return url }, revokeObjectURL: url => revoked.push(url) }, require(name) {
if (name === 'vue') return { ...vue, onBeforeUnmount: callback => disposers.push(callback) }
if (name === 'element-plus') return { ElMessage: { success: () => {} } }
if (name === '@/api/session') return { accessTokenIdentity: token => token }
if (name === '@/modules/auth/public') return { useAuthStore: () => auth }
if (name === '../api/procurement') return { ...defaults, ...api }
if (name === '../procurementSubmission') return { procurementRequestKey: () => `key-${++key}` }
if (name === '../procurementPresentation') return { dateTime: value => value }
throw new Error(`unexpected import ${name}`)
} }
runInNewContext(code, context)
const state = context.exports.default.setup(props, { expose: () => {}, emit: (...args) => events.push(args) })
return { state, auth, calls, events, created, revoked, workflow, dispose: () => disposers.forEach(callback => callback()) }
}
test('both decisions load receipt photos and batch quantities with preview URL cleanup', async () => {
for (const decision of ['confirm', 'reject']) {
const h = harness({ decision })
try {
await flush()
assert.equal(h.state.workflow.value.receivedQuantity, 8)
assert.equal(h.state.workflow.value.receipts.length, 2)
assert.equal(Object.keys(h.state.photos.value).length, 2)
assert.equal(h.state.canSubmit.value, true)
assert.equal(h.state.fileName('F-1'), 'photo.png')
assert.match(source, /order.purchaseQuantity/)
assert.match(source, /receipt.quantity/)
assert.match(source, /<el-image/)
assert.match(source, /preview-src-list/)
} finally { h.dispose() }
assert.deepEqual(h.revoked, h.created)
}
})
test('confirmation uses current version, prevents double submits, and signals list refresh', async () => {
let finish, calls = []
const h = harness({ api: { confirmProcurement: (orderNo, command) => { calls.push({ orderNo, ...command }); return new Promise(resolve => { finish = resolve }) } } })
try {
await flush()
const pending = h.state.submitDecision(); await h.state.submitDecision()
assert.equal(calls.length, 1); assert.equal(calls[0].version, 7); assert.equal(calls[0].orderNo, 'PO-1')
finish({ status: 'CONFIRMED' }); await pending
assert.deepEqual(h.events, [['changed']])
} finally { h.dispose() }
})
test('rejection requires a trimmed reason and retries unchanged payload with the same key', async () => {
const calls = []
const h = harness({ decision: 'reject', api: { rejectProcurement: async (orderNo, command) => { calls.push({ orderNo, ...command }); throw new Error('网络失败') } } })
try {
await flush()
for (const reason of ['', ' ', 'x'.repeat(1001)]) {
h.state.reason.value = reason; await h.state.submitDecision()
assert.match(h.state.error.value, /请填写驳回原因/)
}
assert.equal(calls.length, 0)
h.state.reason.value = ' 照片不清晰 '
await h.state.submitDecision(); await h.state.submitDecision()
assert.equal(calls.length, 2); assert.equal(calls[0].reason, '照片不清晰'); assert.equal(calls[0].version, 7)
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.equal(h.state.reason.value, ' 照片不清晰 '); assert.equal(h.events.length, 0)
h.state.reason.value = '数量不符'; await h.state.submitDecision()
assert.notEqual(calls[1].idempotencyKey, calls[2].idempotencyKey)
} finally { h.dispose() }
})
test('confirmation retry preserves the loaded evidence and idempotency key', async () => {
const calls = []
const h = harness({ api: { confirmProcurement: async (_, command) => { calls.push(command); throw new Error('版本已更新,请刷新') } } })
try {
await flush(); await h.state.submitDecision(); await h.state.submitDecision()
assert.equal(calls.length, 2); assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.equal(h.state.workflow.value.version, 7); assert.equal(Object.keys(h.state.photos.value).length, 2)
assert.equal(h.state.saving.value, false); assert.equal(h.events.length, 0)
} finally { h.dispose() }
})
test('successful rejection sends the trimmed reason and signals list refresh', async () => {
const h = harness({ decision: 'reject' })
try {
await flush(); h.state.reason.value = ' 数量不符 '; await h.state.submitDecision()
assert.equal(h.calls[0].reason, '数量不符'); assert.equal(h.calls[0].version, 7)
assert.deepEqual(h.events, [['changed']])
} finally { h.dispose() }
})
test('photo loading failures can be retried before a decision is submitted', async () => {
let fail = true
const h = harness({ api: { getProcurementFile: async () => { if (fail) throw new Error('failed'); return { type: 'image/png' } } } })
try {
await flush(); await h.state.submitDecision()
assert.equal(h.calls.length, 0); assert.match(h.state.photoError.value, /照片加载失败/)
fail = false; await h.state.load(); await h.state.submitDecision()
assert.equal(h.calls.length, 1)
} finally { h.dispose() }
})
test('stale status, revoked permission and changed account cannot submit decisions', async () => {
const stale = harness({ api: { getProcurementWorkflow: async () => ({ status: 'CONFIRMED', receipts: [] }) } })
try { await flush(); await stale.state.submitDecision(); assert.equal(stale.calls.length, 0) } finally { stale.dispose() }
const h = harness()
try {
await flush(); h.auth.hasPermission = () => false; await h.state.submitDecision(); assert.equal(h.calls.length, 0)
h.auth.hasPermission = () => true; h.auth.token = 'customer-B'; await h.state.submitDecision(); assert.equal(h.calls.length, 0)
} finally { h.dispose() }
})
test('late photo responses after closing do not allocate preview URLs', async () => {
const pending = []
const h = harness({ api: { getProcurementFile: () => new Promise(resolve => pending.push(resolve)) } })
await flush(); h.dispose()
pending.forEach(resolve => resolve({ type: 'image/png' })); await flush()
assert.equal(h.created.length, 0); assert.equal(h.events.length, 0)
})
@@ -122,7 +122,8 @@ export function buildProcurementCreate(draft: ProcurementDraft, customerName: st
if (price > BigInt(Number.MAX_SAFE_INTEGER) || cents(Number(decimal(price))) !== price) throw new Error('预算金额过大,无法准确表示,请核对金额')
if (!['CNY', 'USD'].includes(draft.currency)) throw new Error('预算币种只支持 CNY 或 USD')
if (!/^[+0-9][0-9 ()-]{5,63}$/.test(draft.contactPhone.trim())) throw new Error('请填写快递可联系的电话号码,微信或邮箱不能代替电话')
if (draft.productUrl && (!productHref(draft.productUrl.trim()) || draft.productUrl.trim().length > 2000)) throw new Error('商品链接须为有效的 http 或 https 地址')
if (!draft.productUrl.trim()) throw new Error('请输入商品链接')
if (!productHref(draft.productUrl.trim()) || draft.productUrl.trim().length > 2000) throw new Error('商品链接须为有效的 http 或 https 地址')
if (draft.productSpecification.length > 500 || draft.remark.length > 1000) throw new Error('商品规格或备注超过长度限制')
if (draft.procurementDirection && !['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'].includes(draft.procurementDirection)) throw new Error('请选择有效采购方向')
return { procurementDirection: draft.procurementDirection || 'OVERSEAS_TO_DOMESTIC', customerName: customerName.trim(), purchaseCategory: draft.purchaseCategory.trim(), productName: draft.productName.trim(),
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ApiError } from '@/api/types'
import { accessTokenIdentity } from '@/api/session'
import { useAuthStore } from '@/modules/auth/public'
@@ -9,7 +9,8 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import type { TableAction } from '@/components/common/tableActions'
import ProcurementDetailPanel from '../components/ProcurementDetailPanel.vue'
import { rejectProcurement, confirmProcurement, createProcurement, getProcurement, getProcurementWorkflow, listProcurements, type ProcurementDetail, type ProcurementSummary, type ProcurementWorkflow } from '../api/procurement'
import ProcurementDecisionDialog from '../components/ProcurementDecisionDialog.vue'
import { createProcurement, getProcurement, getProcurementWorkflow, listProcurements, type ProcurementDetail, type ProcurementSummary, type ProcurementWorkflow } from '../api/procurement'
import { procurementDirections, directionLabel, regionText, buildProcurementCreate, dateTime, draftBudgetTotal, emptyProcurementDraft, money, procurementStatuses, statusLabel, statusTone } from '../procurementPresentation'
import { requireOrderBudget } from '../api/orderBudgets'
import { procurementRequestKey } from '../procurementSubmission'
@@ -27,6 +28,7 @@ const formRef = ref<FormInstance>()
const formRules: FormRules<typeof form> = {
procurementDirection: [{ required: true, message: '请选择采购方向', trigger: 'change' }],
productName: [{ required: true, whitespace: true, message: '请输入商品名称', trigger: 'blur' }],
productUrl: [{ required: true, whitespace: true, message: '请输入商品链接', trigger: 'blur' }],
purchaseQuantity: [{ required: true, whitespace: true, message: '请输入采购数量', trigger: 'blur' }],
budgetUnitPrice: [{ required: true, whitespace: true, message: '请输入预算单价', trigger: 'blur' }],
currency: [{ required: true, message: '请选择预算币种', trigger: 'change' }],
@@ -42,13 +44,12 @@ const submitError = ref(''), submittedOrder = ref('')
const submitting = ref(false), loading = ref(false), error = ref('')
const detail = shallowRef<ProcurementDetail | null>(null)
const workflow = shallowRef<ProcurementWorkflow | null>(null)
const confirming = ref(false), confirmError = ref('')
const canConfirm = computed(() => canCreate.value && workflow.value?.status === 'WAIT_CUSTOMER_CONFIRM')
const decisionTarget = shallowRef<ProcurementSummary | null>(null)
const decision = ref<'confirm' | 'reject'>('confirm')
const rows = ref<ProcurementSummary[]>([]), total = ref(0)
const query = reactive({ page: 1, size: 20, orderNo: '', purchaseCategory: '', productName: '', status: '' })
let requestSequence = 0, active = true, inFlight = ''
let createAttempt: { fingerprint: string; key: string } | null = null
let confirmAttempt: { fingerprint: string; key: string } | null = null
const queryModel = computed<QueryModel>({
get: () => ({ orderNo: query.orderNo, purchaseCategory: query.purchaseCategory, productName: query.productName, status: query.status }),
set: value => { for (const key of ['orderNo', 'purchaseCategory', 'productName', 'status'] as const) query[key] = String(value[key] || '') },
@@ -59,15 +60,27 @@ const queryFields: QueryField[] = [
{ key: 'status', label: '履约进度', type: 'select', options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ prop: 'procurementDirection', label: '采购方向', minWidth: 170, slotName: 'direction' },
{ prop: 'orderNo', label: '采购单号', minWidth: 250, slotName: 'orderNo' },
{ prop: 'purchaseCategory', label: '采购品类', width: 120 }, { prop: 'productName', label: '商品名称', minWidth: 200 },
{ prop: 'productSpecification', label: '商品规格', minWidth: 160 }, { prop: 'purchaseQuantity', label: '采购数量(件)', width: 135 },
{ prop: 'budgetUnitPrice', label: '预算单价', width: 155, slotName: 'budget' },
{ prop: 'status', label: '履约进度', width: 180, slotName: 'status' }, { prop: 'createdAt', label: '下单时间', width: 185, slotName: 'createdAt' },
{ prop: 'status', label: '履约进度', width: 180, slotName: 'status' }, { prop: 'reviewRemark', label: '拒绝原因', minWidth: 220, slotName: 'reviewRemark' }, { prop: 'createdAt', label: '下单时间', width: 185, slotName: 'createdAt' },
]
function rowActions(row: object): TableAction[] { return [{ key: 'detail', label: '详情', run: () => openDetail(row as ProcurementSummary) }] }
function rowActions(row: object): TableAction[] {
const order = row as ProcurementSummary
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(order) }]
if (canCreate.value && order.status === 'WAIT_CUSTOMER_CONFIRM') {
actions.push({ key: 'reject', label: '驳回', run: () => beginDecision(order, 'reject') }, { key: 'confirm', label: '确认采购结果', run: () => beginDecision(order, 'confirm') })
}
return actions
}
function beginDecision(order: ProcurementSummary, kind: 'confirm' | 'reject') {
if (!canCreate.value || order.status !== 'WAIT_CUSTOMER_CONFIRM') return
decision.value = kind; decisionTarget.value = order
}
function decisionChanged() { decisionTarget.value = null; void loadList() }
function openDetail(row: ProcurementSummary) { if (row.orderNo) void router.push({ name: 'cross-border-procurement-detail', params: { orderNo: row.orderNo } }) }
function returnToList() { if (canList.value) void router.push({ name: 'cross-border-procurement' }) }
function message(cause: unknown) {
@@ -95,39 +108,6 @@ async function loadDetail() {
catch (cause) { if (sequence === requestSequence) error.value = message(cause) }
finally { if (sequence === requestSequence) { loading.value = false; inFlight = '' } }
}
async function confirmReceived() {
if (!canConfirm.value || confirming.value || !workflow.value) return
const owner = identity.value, targetOrder = orderNo.value, version = workflow.value.version
confirming.value = true; confirmError.value = ''
try {
await ElMessageBox.confirm('确认本单收货数量及照片,确认后平台将安排出库。', '确认采购结果', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
if (!active || owner !== identity.value || targetOrder !== orderNo.value) return
const fingerprint = JSON.stringify([owner, targetOrder, version])
if (confirmAttempt?.fingerprint !== fingerprint) confirmAttempt = { fingerprint, key: procurementRequestKey() }
const result = await confirmProcurement(targetOrder, { version, idempotencyKey: confirmAttempt.key })
if (!active || owner !== identity.value || targetOrder !== orderNo.value) return
workflow.value = result; confirmAttempt = null
if (detail.value) detail.value = { ...detail.value, order: { ...detail.value.order, status: result.status, version: result.version } }
ElMessage.success('采购结果已确认')
} catch (cause) {
if (cause !== 'cancel' && cause !== 'close' && active && owner === identity.value && targetOrder === orderNo.value) confirmError.value = message(cause)
} finally { confirming.value = false }
}
// 驳回携带当前版本和必填原因,失败保留当前采购详情。
async function rejectReceived() {
if (!canConfirm.value || confirming.value || !workflow.value) return
const owner = identity.value, targetOrder = orderNo.value
confirming.value = true; confirmError.value = ''
try {
const answer = await ElMessageBox.prompt('请说明采购结果不符合要求的原因,平台将重新上传照片。', '驳回采购结果', { inputType: 'textarea', inputValidator: value => !!value?.trim() && value.trim().length <= 1000 || '请填写驳回原因,最多1000字', confirmButtonText: '提交驳回', cancelButtonText: '取消' })
if (!active || owner !== identity.value || targetOrder !== orderNo.value || !workflow.value) return
const result = await rejectProcurement(targetOrder, { version: workflow.value.version, idempotencyKey: procurementRequestKey(), reason: answer.value.trim() })
if (!active || owner !== identity.value || targetOrder !== orderNo.value) return
workflow.value = result; ElMessage.success('已驳回,等待平台重新上传照片')
} catch (cause) {
if (cause !== 'cancel' && cause !== 'close' && active && owner === identity.value && targetOrder === orderNo.value) confirmError.value = message(cause)
} finally { confirming.value = false }
}
function search() { if (query.page === 1) void loadList(); else query.page = 1 }
function resetQuery() { const reload = query.page === 1; Object.assign(query, { page: 1, orderNo: '', purchaseCategory: '', productName: '', status: '' }); if (reload) void loadList() }
function resetForm() { submitError.value = ''; submittedOrder.value = ''; Object.assign(form, emptyProcurementDraft()); formRef.value?.clearValidate(); createAttempt = null }
@@ -145,14 +125,14 @@ async function submit() {
const result = await createProcurement({ ...payload, idempotencyKey: createAttempt.key })
if (!active || owner !== identity.value) return
createAttempt = null; submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交')
if (canList.value) await router.push({ name: 'cross-border-procurement-detail', params: { orderNo: result.order.orderNo } })
if (canList.value) await router.push({ name: 'cross-border-procurement' })
} catch (cause) { if (/已释放|已过期/.test(message(cause))) createAttempt = null; if (active && owner === identity.value) submitError.value = message(cause) }
finally { submitting.value = false }
}
watch(() => [mode.value, orderNo.value, query.page, query.size, identity.value], (values, previous) => {
if (values[4] !== previous?.[4]) { rows.value = []; total.value = 0; createAttempt = null }
if (values[4] !== previous?.[4] || values[1] !== previous?.[1]) confirmAttempt = null
++requestSequence; inFlight = ''; loading.value = false; error.value = ''; detail.value = null; workflow.value = null; confirmError.value = ''
decisionTarget.value = null
++requestSequence; inFlight = ''; loading.value = false; error.value = ''; detail.value = null; workflow.value = null
if (mode.value === 'create') resetForm()
else if (mode.value === 'detail') void loadDetail()
else void loadList()
@@ -172,14 +152,15 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<template #budget="{ row }">{{ money((row as ProcurementSummary).budgetUnitPrice, (row as ProcurementSummary).currency) }}</template>
<template #direction="{ row }">{{ directionLabel((row as ProcurementSummary).procurementDirection) }}</template>
<template #status="{ row }"><el-tag :type="statusTone((row as ProcurementSummary).status)" size="small">{{ statusLabel((row as ProcurementSummary).status, (row as ProcurementSummary).procurementDirection) }}</el-tag></template>
<template #reviewRemark="{ row }"><span v-if="(row as ProcurementSummary).status === 'REJECTED'">{{ (row as ProcurementSummary).reviewRemark || '未填写原因' }}</span><span v-else>—</span></template>
<template #createdAt="{ row }">{{ dateTime((row as ProcurementSummary).createdAt) }}</template>
</DataTable>
</div>
</template>
<section v-else-if="mode === 'detail'" v-loading="loading" class="procurement-detail" aria-label="采购订单详情">
<header class="procurement-heading"><div><el-button link type="primary" @click="returnToList">← 返回列表</el-button><h2>{{ detail?.order.orderNo || '采购订单详情' }}</h2></div><div><el-tag v-if="detail" :type="statusTone(workflow?.status || detail.order.status)">{{ statusLabel(workflow?.status || detail.order.status, detail.order.procurementDirection) }}</el-tag><el-button v-if="canConfirm" type="danger" :disabled="confirming" @click="rejectReceived">驳回</el-button><el-button v-if="canConfirm" type="primary" :loading="confirming" @click="confirmReceived">确认采购结果</el-button><el-button :disabled="loading || confirming" @click="loadDetail">刷新</el-button></div></header>
<header class="procurement-heading"><div><el-button link type="primary" @click="returnToList">← 返回列表</el-button><h2>{{ detail?.order.orderNo || '采购订单详情' }}</h2></div><div><el-tag v-if="detail" :type="statusTone(workflow?.status || detail.order.status)">{{ statusLabel(workflow?.status || detail.order.status, detail.order.procurementDirection) }}</el-tag><el-button :disabled="loading" @click="loadDetail">刷新</el-button></div></header>
<div v-if="error" class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadDetail">重新加载</el-button></div>
<template v-else-if="detail && workflow"><el-alert v-if="confirmError" :title="confirmError" type="error" :closable="false" /><el-alert v-if="workflow.rejectionReason" title="采购结果驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
<template v-else-if="detail && workflow"><el-alert v-if="workflow.rejectionReason" title="采购结果驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
</section>
<section v-else class="procurement-create" aria-label="采购下单">
<header class="procurement-create-heading">
@@ -225,7 +206,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<el-form-item label="采购数量(件)" for="procurement-quantity" prop="purchaseQuantity" required><el-input id="procurement-quantity" v-model.trim="form.purchaseQuantity" inputmode="numeric" maxlength="18" placeholder="正整数" /></el-form-item>
<el-form-item label="预算单价" for="procurement-price" prop="budgetUnitPrice" required><el-input id="procurement-price" v-model.trim="form.budgetUnitPrice" inputmode="decimal" maxlength="20" placeholder="请明确填写;免费样品填 0" /></el-form-item>
<el-form-item label="预算币种" for="procurement-currency" prop="currency" required><el-select id="procurement-currency" v-model="form.currency" popper-class="procurement-create-select"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item class="full" label="商品链接" for="procurement-url"><el-input id="procurement-url" v-model.trim="form.productUrl" maxlength="2000" placeholder="https://..." /></el-form-item>
<el-form-item class="full" label="商品链接" for="procurement-url" prop="productUrl" required><el-input id="procurement-url" v-model.trim="form.productUrl" maxlength="2000" placeholder="https://..." /></el-form-item>
</div>
<div class="procurement-budget-total">货款预算总上限:{{ money(budgetTotal, form.currency) }}</div>
</div>
@@ -237,7 +218,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<el-form-item label="联系电话" for="procurement-phone" prop="contactPhone" required><el-input id="procurement-phone" v-model.trim="form.contactPhone" maxlength="64" type="tel" placeholder="快递可联系的电话" /></el-form-item>
<el-form-item class="full" :label="regionText('完整国内地址', form.procurementDirection)" for="procurement-address" prop="receivingAddress" required><el-input id="procurement-address" v-model.trim="form.receivingAddress" maxlength="1000" :placeholder="form.procurementDirection === 'DOMESTIC_TO_OVERSEAS' ? '国家、州/省、市、邮编及详细地址' : '省、市、区县、街道及详细门牌'" /></el-form-item>
<el-form-item class="full" label="采购要求" for="procurement-requirements">
<el-input id="procurement-requirements" v-model="form.remark" type="textarea" :rows="4" maxlength="1000" resize="vertical" aria-describedby="procurement-requirements-count" />
<el-input id="procurement-requirements" v-model="form.remark" type="textarea" :rows="4" maxlength="1000" resize="vertical" placeholder="填写采购要求(选填)" aria-describedby="procurement-requirements-count" />
<div id="procurement-requirements-count" class="procurement-character-count">{{ form.remark.length }} / 1000</div>
</el-form-item>
</div>
@@ -246,6 +227,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<footer class="procurement-form-actions"><el-button v-if="canList" :disabled="submitting" @click="returnToList">返回列表</el-button><el-button type="primary" :loading="submitting" :disabled="!companyName || !identity" @click="submit">提交采购订单</el-button></footer>
</template>
</section>
<ProcurementDecisionDialog v-if="decisionTarget" :key="`${identity}:${decisionTarget.orderNo}:${decision}`" :order="decisionTarget" :decision="decision" @close="decisionTarget = null" @changed="decisionChanged" />
</section>
</template>
@@ -58,6 +58,7 @@ let detailRequest = 0
onBeforeUnmount(() => { disposed = true; detailRequest += 1; resetClearanceResults() })
const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? [])
const pendingReceipts = computed(() => detail.value?.documents.filter((item) => item.receipt && item.receiptStatus !== 'CONFIRMED') ?? [])
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]
@@ -273,29 +274,43 @@ async function downloadFile(document: MerchantCustomsDocument, kind: 'source' |
}
}
async function confirmReceipt(document: MerchantCustomsDocument) {
async function confirmReceipt() {
const documents = pendingReceipts.value
if (!documents.length) return
let confirmedCount = 0
try {
await ElMessageBox.confirm('确认已核对整份报关行回执吗?确认后平台会把确认结果发送给报关行执行。', '确认报关行回执', { type: 'warning' })
await ElMessageBox.confirm(`确认已核对 ${documents.length} 份报关行回执吗?确认后平台会把确认结果发送给报关行执行。`, '确认报关行回执', { type: 'warning' })
mutating.value = true
await confirmMerchantCustomsReceipt(document)
ElMessage.success('回执确认成功')
for (const document of documents) {
await confirmMerchantCustomsReceipt(document)
confirmedCount += 1
}
ElMessage.success(`已确认 ${documents.length} 份回执`)
await loadDetail()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(merchantCustomsErrorMessage(error, '回执确认失败'))
if (confirmedCount) await loadDetail()
ElMessage.error(`${confirmedCount ? `已确认 ${confirmedCount}/${documents.length} 份,` : ''}${merchantCustomsErrorMessage(error, '回执确认失败')}`)
} finally { mutating.value = false }
}
async function rejectReceipt(document: MerchantCustomsDocument) {
async function rejectReceipt() {
const documents = pendingReceipts.value
if (!documents.length) return
let rejectedCount = 0
try {
const reason = await ElMessageBox.prompt('请填写打回原因', '打回报关行回执', { inputValidator: (value) => value.trim() ? true : '请输入打回原因' })
mutating.value = true
await rejectMerchantCustomsReceipt(document, reason.value)
ElMessage.success('回执已打回')
for (const document of documents) {
await rejectMerchantCustomsReceipt(document, reason.value)
rejectedCount += 1
}
ElMessage.success(`已打回 ${documents.length} 份回执`)
await loadDetail()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(merchantCustomsErrorMessage(error, '回执打回失败'))
if (rejectedCount) await loadDetail()
ElMessage.error(`${rejectedCount ? `已打回 ${rejectedCount}/${documents.length} 份,` : ''}${merchantCustomsErrorMessage(error, '回执打回失败')}`)
} finally { mutating.value = false }
}
@@ -404,6 +419,10 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
<section class="customs-section">
<div class="customs-section-title">
<h3>资料版本历史</h3>
<div v-if="pendingReceipts.length && detail.status === 'DECLARING'" class="customs-receipt-actions">
<el-button type="success" :disabled="mutating" @click="confirmReceipt">确认全部回执</el-button>
<el-button type="danger" plain :disabled="mutating" @click="rejectReceipt">打回全部回执</el-button>
</div>
</div>
<el-empty v-if="!detail.documents.length" description="尚未上传退税报关资料" :image-size="54" />
<div v-else class="customs-document-list">
@@ -422,10 +441,6 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
<el-button link type="primary" :loading="downloadingKey === `${item.documentId}:source`" @click="downloadFile(item, 'source')"><Download :size="15" />源文件</el-button>
<el-button v-if="item.receipt" link type="success" :loading="downloadingKey === `${item.documentId}:receipt`" @click="downloadFile(item, 'receipt')"><Download :size="15" />报关行回执</el-button>
<el-button v-if="item.finalDocument" link type="success" :loading="downloadingKey === `${item.documentId}:final`" @click="downloadFile(item, 'final')"><Download :size="15" />报关完税文件</el-button>
<template v-if="item.receipt && item.receiptStatus !== 'CONFIRMED' && detail.status === 'DECLARING'">
<el-button link type="success" :disabled="mutating" @click="confirmReceipt(item)">确认整份回执</el-button>
<el-button link type="danger" :disabled="mutating" @click="rejectReceipt(item)">打回回执</el-button>
</template>
<el-tag v-if="item.receipt" size="small" :type="item.receiptStatus === 'CONFIRMED' ? 'success' : item.receiptStatus === 'REJECTED' ? 'danger' : 'warning'">{{ item.receiptStatus === 'CONFIRMED' ? '已确认' : item.receiptStatus === 'REJECTED' ? `已打回:${item.receiptRejectReason || '待补充'}` : '待确认' }}</el-tag>
<template v-if="canEdit && !locked && !item.frozen">
<el-button link type="primary" :disabled="mutating" @click="beginEditInbounds(item)"><Pencil :size="15" />改关联</el-button>
@@ -472,6 +487,7 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
.clearance-result-row p { grid-column:1 / -1; margin:0; color:var(--navy); font-size:12px; }
.customs-section-title { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
.customs-receipt-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.customs-help-heading { display: flex; align-items: center; gap: 4px; }
.customs-inbound-options { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 14px; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: var(--client-header-bg); }
.customs-inbound-options :deep(.el-checkbox) { margin-right: 0; }