This commit is contained in:
2026-10-30 17:59:48 +08:00
parent d6ef770e48
commit ad72ab8406
10 changed files with 209 additions and 42 deletions
@@ -59,6 +59,7 @@ export interface TransportDetail {
originAddress: string | null
destinationAddress: string | null
remark: string | null
histories?: Array<{ operationType: string; toStatus: string; remark: string | null }>
}
export interface TransportCreate {
+7 -10
View File
@@ -1,13 +1,10 @@
import { apiRequest } from '@/api/http'
interface BudgetResult { status: string; usable: boolean; amountCny: number | string; message: string; orderNo: string | null }
export interface BudgetResult { status: string; usable: boolean; amountCny: number | string; message: string; orderNo: string | null }
const base = '/merchant/v1/last-leg/order-budgets'
// 资金回执成功才继续下单;超时保留当前幂等键,允许用户再次核对。
export async function requireOrderBudget(kind: 'procurement' | 'trucking', key: string, amount: number, currency: string) {
const url = `${base}/${kind}/${encodeURIComponent(key)}`
let result = await apiRequest<BudgetResult>({ method: 'POST', url, data: { amount, currency } })
for (let attempt = 0; result.status === 'FREEZE_PENDING' && attempt < 12; attempt++) {
await new Promise(resolve => setTimeout(resolve, 1000))
result = await apiRequest<BudgetResult>({ method: 'GET', url })
}
if (!result.usable) throw new Error(result.message || '余额及授信校验未完成,请稍后重试')
// 创建订单时后端在同一事务记录冻结请求,客户端只在详情核对状态和重试明确失败。
export function readOrderFunding(kind: 'procurement' | 'trucking', orderNo: string) {
return apiRequest<BudgetResult>({ method: 'GET', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) })
}
export function retryOrderFunding(kind: 'procurement' | 'trucking', orderNo: string) {
return apiRequest<BudgetResult>({ method: 'POST', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) + '/retry' })
}
@@ -76,7 +76,7 @@ test('covers list detail create loading error empty pagination and actions-first
assert.match(view, /key: 'actions', label: '操作', width: 90, fixed: 'left'/)
assert.match(view, /class="business-table-card"/)
assert.doesNotMatch(procurementView, /inventoryQuantity|outboundQuantity/)
assert.match(procurementView, /label: '拒绝原因'/)
assert.match(procurementView, /label: '失败 \/ 拒绝原因'/)
assert.match(procurementView, /reviewRemark/)
assert.match(procurementView, /procurementStatuses/)
assert.match(procurementView, /ProcurementDetailPanel/)
@@ -213,15 +213,9 @@ test('looseboard requires both times and rejects backwards schedule', () => {
})
test('truck creation waits for budget freeze and never submits after insufficient funds', async () => {
let release
const gate = new Promise(resolve => { release = resolve })
const current = transportHarness('trucking', false, () => gate)
test('truck creation does not block on a separate budget reservation', async () => {
const current = transportHarness('trucking', false, async () => { throw new Error('旧冻结接口不应调用') })
Object.assign(current.form, { vehicleType: 'PALLET_30', estimatedPalletCount: 3, budgetAmount: 100, origin: 'LA', destination: 'ONT', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-10-20T09:00:00', deliveryAt: '2026-10-20T15:00:00' })
const pending = current.submit(); await Promise.resolve(); await Promise.resolve()
assert.equal(current.calls.length, 0)
release(); await pending; assert.equal(current.calls.length, 1)
const failed = transportHarness('trucking', false, async () => { throw new Error('余额及授信不足') })
Object.assign(failed.form, { ...current.form, vehicleType: 'PALLET_30', estimatedPalletCount: 3, budgetAmount: 100, origin: 'LA', destination: 'ONT', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-10-20T09:00:00', deliveryAt: '2026-10-20T15:00:00' })
await failed.submit(); assert.equal(failed.calls.length, 0); assert.equal(failed.form.budgetAmount, 100)
await current.submit()
assert.equal(current.calls.length, 1)
})
@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance } from 'element-plus'
import TransportDemandForm from './TransportDemandForm.vue'
import { requireOrderBudget } from '../api/orderBudgets'
import OrderFundingStatus from './OrderFundingStatus.vue'
import { createTransportDemandForm, transportDemandError, buildTransportDemand, type TransportDemandForm as DemandFormModel } from '../transportDemandForm'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
@@ -67,6 +67,7 @@ function transportColumns(looseboard: boolean): ColumnDefinition[] {
{ key: 'pickupAt', label: pickupLabel, width: 165 }, { key: 'deliveryAt', label: deliveryLabel, width: 165 },
]),
{ key: 'status', label: '状态', width: 130 },
...(!looseboard ? [{ key: 'failureReason', label: '失败 / 拒绝原因', width: 240 }] : []),
{ key: 'fleetArrangement', label: '承运安排', width: 190 },
]
}
@@ -105,6 +106,8 @@ const queryFields = computed<QueryField[]>(() => [
type: 'select',
placeholder: '全部',
options: [
{ label: '订单受理中', value: 'FUNDING_PENDING' },
{ label: '订单处理失败', value: 'FUNDING_FAILED' },
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '已通过', value: 'APPROVED' },
{ label: '已拒绝', value: 'REJECTED' },
@@ -144,7 +147,7 @@ const tableColumns = computed<DataTableColumn[]>(() => [
...configuration.value.columns.map((column) => ({
...column,
prop: column.key,
slotName: ['orderNo', 'status', 'fleetArrangement'].includes(column.key) ? column.key : undefined,
slotName: ['orderNo', 'status', 'failureReason', 'fleetArrangement'].includes(column.key) ? column.key : undefined,
formatter: (row: object) => displayCell(row, column.key),
})),
])
@@ -164,10 +167,10 @@ function localMinute(value: unknown) { return value ? String(value).replace('T',
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
function statusLabel(value: unknown) {
if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)]
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
return ({ FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
}
function statusTone(value: unknown) {
return ({ APPROVED: 'success', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'>)[String(value)] || 'info'
return ({ APPROVED: 'success', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info', FUNDING_PENDING: 'warning', FUNDING_FAILED: 'danger', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'>)[String(value)] || 'info'
}
function displayCell(candidate: unknown, key: string) {
const row = candidate as BusinessRow
@@ -264,15 +267,17 @@ async function submit() {
try {
if (props.kind === 'trucking') {
const payload = { ...buildTransportDemand('truck', form), idempotencyKey: stableSubmitKey }
await requireOrderBudget('trucking', stableSubmitKey, payload.budgetAmount!, payload.currency)
await createTransport('trucking', payload)
}
else await createTransport('looseboard', { ...buildTransportDemand('looseboard', form), idempotencyKey: stableSubmitKey })
ElMessage.success('订单提交成功')
ElMessage.success(props.kind === 'trucking' ? '本土卡订单已受理,资金校验完成后进入平台审核' : '订单提交成功')
resetForm()
if (props.createPage) await router.push({ name: listRouteName.value })
else { mode.value = 'list'; await load() }
} catch (cause) { if (/已释放|已过期/.test(errorMessage(cause))) submitKey.value = ''; ElMessage.error(errorMessage(cause)) }
} catch (cause) {
if (/已释放|已过期/.test(errorMessage(cause))) submitKey.value = ''
ElMessage.error(errorMessage(cause))
}
finally { submitting.value = false }
}
@@ -298,6 +303,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<template v-if="error" #error><div class="business-load-error" role="alert"><span>{{ error }}</span><el-button size="small" @click="load">重新加载</el-button></div></template>
<template #orderNo="{ row }"><button class="business-order-link" type="button" @click="openDetail(row)">{{ (row as BusinessRow).orderNo }}</button></template>
<template #status="{ row }"><el-tag :type="statusTone((row as BusinessRow).status)" effect="light" size="small">{{ statusLabel((row as BusinessRow).status) }}</el-tag></template>
<template #failureReason="{ row }"><OrderFundingStatus v-if="['FUNDING_FAILED', 'REJECTED'].includes((row as BusinessRow).status)" kind="trucking" :order-no="(row as BusinessRow).orderNo" :status="(row as BusinessRow).status" :can-retry="false" compact /><span v-else>—</span></template>
<template #fleetArrangement="{ row }"><template v-if="(row as TransportSummary).fleetArrangement"><div>{{ poolOrderStatusLabels[(row as TransportSummary).fleetArrangement!.fulfillmentStage] || '待核对履约进度' }}</div><small>{{ dateTime((row as TransportSummary).fleetArrangement!.plannedDepartureAt) }}</small></template><span v-else>—</span></template>
</DataTable>
</div>
@@ -308,6 +314,8 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<div class="business-detail-heading-main"><el-button link type="primary" @click="returnToList">← 返回列表</el-button><div><h2>{{ detail?.summary.orderNo || '订单详情' }}</h2></div></div>
<el-tag :type="statusTone(detail?.summary.status)" effect="light">{{ detailStatus }}</el-tag>
</header>
<el-button :disabled="loading" @click="loadDetail">刷新</el-button>
<OrderFundingStatus v-if="kind === 'trucking' && detail" kind="trucking" :order-no="detail.summary.orderNo" :status="detail.summary.status" :can-retry="canCreate" @changed="loadDetail" />
<div v-if="error" class="business-detail-error" role="alert"><strong>{{ error }}</strong><el-button size="small" @click="loadDetail">重新加载</el-button></div>
<section v-else class="business-detail-section">
<h3>订单信息</h3>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { onActivated, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { readOrderFunding, retryOrderFunding } from '../api/orderBudgets'
import { getBusinessOrder, type TransportDetail } from '../api/businessOrders'
const props = defineProps<{ kind: 'procurement' | 'trucking'; orderNo: string; status: string; canRetry: boolean; compact?: boolean }>()
const emit = defineEmits<{ changed: [] }>()
const message = ref(''), busy = ref(false)
let generation = 0, timer: ReturnType<typeof setTimeout> | undefined
let active = true
// 自动核对订单受理中状态;离开详情或切换订单时丢弃旧响应。
watch(() => [props.kind, props.orderNo, props.status], () => {
generation++; clearTimeout(timer); message.value = ''; busy.value = false
if (active && (['FUNDING_PENDING', 'FUNDING_FAILED'].includes(props.status) || (props.kind === 'trucking' && props.status === 'REJECTED'))) void check(generation, 0)
}, { immediate: true })
async function check(sequence: number, count: number) {
try {
// 卡车审核拒绝原因来自审核历史,不使用客户填写的订单备注。
if (props.kind === 'trucking' && props.status === 'REJECTED') {
const detail = await getBusinessOrder<TransportDetail>('trucking', props.orderNo)
if (sequence !== generation) return
const rejection = [...(detail.histories || [])].reverse().find(item => item.operationType === 'REVIEW' && item.toStatus === 'REJECTED')
message.value = rejection?.remark?.trim() || '未填写拒绝原因'
return
}
const result = await readOrderFunding(props.kind, props.orderNo)
if (sequence !== generation) return
message.value = result.status === 'FREEZE_PENDING' ? '订单受理中,处理完成后将自动进入待审核。' : result.message || (result.status === 'FAILED' ? '订单处理失败,请联系平台核对原因' : '')
if (result.status === 'FROZEN' || (result.status === 'FAILED' && props.status === 'FUNDING_PENDING') || (result.status === 'FREEZE_PENDING' && props.status === 'FUNDING_FAILED')) emit('changed')
else if (result.status === 'FREEZE_PENDING' && count < 30) timer = setTimeout(() => void check(sequence, count + 1), 2000)
} catch (cause) {
if (sequence === generation) message.value = cause instanceof Error ? cause.message : '处理原因读取失败,请刷新重试'
}
}
async function retry() {
if (!active || busy.value || !props.canRetry || props.status !== 'FUNDING_FAILED') return
const sequence = generation; busy.value = true
try {
await retryOrderFunding(props.kind, props.orderNo)
if (sequence === generation) emit('changed')
} catch (cause) {
if (sequence === generation) message.value = cause instanceof Error ? cause.message : '重试未完成,请稍后再试'
} finally { if (sequence === generation) busy.value = false }
}
function stop() { active = false; generation++; clearTimeout(timer) }
onDeactivated(stop)
onBeforeUnmount(stop)
onActivated(() => {
if (active) return
active = true; busy.value = false
if (['FUNDING_PENDING', 'FUNDING_FAILED'].includes(props.status) || (props.kind === 'trucking' && props.status === 'REJECTED')) void check(++generation, 0)
})
</script>
<template>
<div v-if="['FUNDING_PENDING', 'FUNDING_FAILED'].includes(status) || (kind === 'trucking' && status === 'REJECTED')" role="status" aria-live="polite">
<span v-if="compact">{{ message || '正在读取处理失败原因…' }}</span>
<template v-else>
<el-alert :type="status === 'FUNDING_PENDING' ? 'info' : 'error'" :closable="false" :title="status === 'REJECTED' ? '订单已拒绝' : status === 'FUNDING_FAILED' ? '订单处理失败' : '订单受理中'" :description="message" />
<el-button v-if="status === 'FUNDING_FAILED' && canRetry" :loading="busy" @click="retry">重新处理订单</el-button>
</template>
</div>
</template>
@@ -70,7 +70,7 @@ defineExpose({
</div>
</section>
<footer class="transport-demand-actions">
<el-button :disabled="submitting" @click="emit('cancel')">{{ isCreate ? '返回列表' : '取消' }}</el-button>
<el-button v-if="!isCreate" :disabled="submitting" @click="emit('cancel')">取消</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
</footer>
</el-form>
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { runInNewContext } from 'node:vm'
import ts from 'typescript'
const source = readFileSync(new URL('./components/OrderFundingStatus.vue', import.meta.url), 'utf8')
const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm, '')
function harness({ status = 'FUNDING_PENDING', read = async () => ({ status: 'FREEZE_PENDING' }), retry = async () => ({}), detail = async () => ({}) } = {}) {
const props = { kind: 'procurement', orderNo: 'CG1', status, canRetry: true }
const timers = new Map(), events = [], hooks = {}
let nextTimer = 0
const context = {
defineProps: () => props, defineEmits: () => event => events.push(event), ref: value => ({ value }),
watch: (_getter, callback) => { hooks.watch = callback },
onBeforeUnmount: callback => { hooks.unmount = callback },
onActivated: callback => { hooks.activate = callback },
onDeactivated: callback => { hooks.deactivate = callback },
setTimeout: callback => { const id = ++nextTimer; timers.set(id, callback); return id },
clearTimeout: id => timers.delete(id), readOrderFunding: read, retryOrderFunding: retry, getBusinessOrder: detail,
}
const compiled = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const api = runInNewContext(`${compiled}\n({check, retry, message, busy})`, context)
return { ...api, props, timers, events, hooks }
}
test('truck rejection uses latest review reason and never customer remarks', async () => {
const h = harness({ status: 'REJECTED', detail: async () => ({ remark: '客户备注', histories: [
{ operationType: 'REVIEW', toStatus: 'REJECTED', remark: '旧原因' },
{ operationType: 'REVIEW', toStatus: 'REJECTED', remark: '路线暂不支持' },
{ operationType: 'UPDATE', toStatus: 'REJECTED', remark: '内部修改' },
] }) })
h.props.kind = 'trucking'
await h.check(0, 0)
assert.equal(h.message.value, '路线暂不支持')
assert.equal(h.timers.size, 0)
const missing = harness({ status: 'REJECTED', detail: async () => ({ remark: '客户备注' }) })
missing.props.kind = 'trucking'; await missing.check(0, 0)
assert.equal(missing.message.value, '未填写拒绝原因')
})
test('funding status polls for a bounded time and refreshes detail only on a result', async () => {
let count = 0
const h = harness({ read: async () => { count++; return { status: 'FREEZE_PENDING' } } })
await h.check(0, 0)
assert.equal(h.timers.size, 1)
for (let i = 1; i <= 30; i++) {
const [id, callback] = h.timers.entries().next().value
h.timers.delete(id); callback(); await new Promise(resolve => setImmediate(resolve))
}
assert.equal(count, 31)
assert.equal(h.timers.size, 0)
assert.equal(h.events.length, 0)
for (const status of ['FROZEN', 'FAILED']) {
const terminal = harness({ read: async () => ({ status, message: '余额不足' }) })
await terminal.check(0, 0)
assert.equal(terminal.events.join(','), 'changed')
assert.equal(terminal.timers.size, 0)
}
})
test('retry keeps original order and suppresses double clicks and stale responses', async () => {
let resolve, calls = 0
const h = harness({ status: 'FUNDING_FAILED', retry: (kind, no) => {
calls++; assert.equal(kind, 'procurement'); assert.equal(no, 'CG1')
return new Promise(done => { resolve = done })
} })
const first = h.retry(); await h.retry()
assert.equal(calls, 1)
h.hooks.deactivate(); resolve({}); await first
assert.equal(h.events.length, 0)
const denied = harness({ status: 'FUNDING_FAILED', retry: async () => { throw new Error('should not call') } })
denied.props.canRetry = false; await denied.retry()
assert.equal(denied.message.value, '')
})
test('switching or leaving an order ignores the old funding result', async () => {
let resolve
const h = harness({ read: () => new Promise(done => { resolve = done }) })
const pending = h.check(0, 0)
h.hooks.unmount(); resolve({ status: 'FAILED', message: 'old order' }); await pending
assert.equal(h.message.value, '')
assert.equal(h.events.length, 0)
assert.equal(h.timers.size, 0)
})
test('failed orders display finance reason and refresh when another session retries', async () => {
const failed = harness({ status: 'FUNDING_FAILED', read: async () => ({ status: 'FAILED', message: '可用余额及授信不足' }) })
await failed.check(0, 0)
assert.equal(failed.message.value, '可用余额及授信不足')
assert.equal(failed.events.length, 0)
const retried = harness({ status: 'FUNDING_FAILED' })
await retried.check(0, 0)
assert.equal(retried.events.join(','), 'changed')
})
@@ -271,13 +271,15 @@ test('bill-settled manual fees show review results without counting a balance de
})
test('insufficient balance and credit blocks procurement creation and preserves the draft', async () => {
test('procurement creates immediately while funding is asynchronous and exposes funding states', async () => {
let creates = 0
const harness = viewHarness({ createPage: true, api: { requireOrderBudget: async () => { throw new Error('余额及授信不足') }, createProcurement: async () => { creates++ } } })
const harness = viewHarness({ createPage: true, api: { createProcurement: async () => { creates++; return { order: { orderNo: 'CG-PENDING', status: 'FUNDING_PENDING' } } } } })
try {
Object.assign(harness.state.form, draft()); await harness.state.submit()
assert.equal(creates, 0); assert.equal(harness.state.submitError.value, '余额及授信不足')
assert.equal(harness.state.form.productName, draft().productName)
assert.equal(creates, 1); assert.equal(harness.state.submitError.value, '')
assert.equal(harness.state.submittedOrder.value, 'CG-PENDING')
assert.equal(presentation.statusLabel('FUNDING_PENDING'), '订单受理中')
assert.equal(presentation.statusLabel('FUNDING_FAILED'), '订单处理失败')
} finally { harness.dispose() }
})
@@ -1,7 +1,7 @@
import type { Amount, ProcurementCreate, ProcurementFee } from './api/procurement'
export const procurementStatuses: Record<string, string> = {
PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成',
REJECTED: '已拒绝', CANCELLED: '已取消',
}
@@ -12,7 +12,7 @@ export const feePurposes: Record<string, string> = {
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || '待核对进度' : '—', direction) }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'COMPLETED' || status === 'CONFIRMED') return 'success'
if (status === 'REJECTED') return 'danger'
if ((status === 'REJECTED' || status === 'FUNDING_FAILED')) return 'danger'
if (status === 'CANCELLED') return 'info'
if (status === 'IN_TRANSIT' || status === 'DELIVERING') return 'primary'
return 'warning'
@@ -12,7 +12,7 @@ import ProcurementDetailPanel from '../components/ProcurementDetailPanel.vue'
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 OrderFundingStatus from '../components/OrderFundingStatus.vue'
import { procurementRequestKey } from '../procurementSubmission'
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
@@ -66,7 +66,7 @@ const columns: DataTableColumn[] = [
{ 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: 'reviewRemark', label: '拒绝原因', minWidth: 220, slotName: 'reviewRemark' }, { 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[] {
const order = row as ProcurementSummary
@@ -120,13 +120,16 @@ async function submit() {
const payload = buildProcurementCreate(form, companyName.value, '')
const fingerprint = JSON.stringify([owner, payload])
if (createAttempt?.fingerprint !== fingerprint) createAttempt = { fingerprint, key: procurementRequestKey() }
await requireOrderBudget('procurement', createAttempt.key, Number((payload.budgetUnitPrice * payload.purchaseQuantity).toFixed(2)), payload.currency)
if (!active || owner !== identity.value) return
const result = await createProcurement({ ...payload, idempotencyKey: createAttempt.key })
if (!active || owner !== identity.value) return
createAttempt = null; submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交')
createAttempt = null; submittedOrder.value = result.order.orderNo
ElMessage.success(result.order.status === 'FUNDING_PENDING' ? '采购订单已受理,资金校验完成后进入平台审核' : '采购订单已提交')
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) }
} 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) => {
@@ -152,13 +155,14 @@ 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 #reviewRemark="{ row }"><OrderFundingStatus v-if="(row as ProcurementSummary).status === 'FUNDING_FAILED'" kind="procurement" :order-no="(row as ProcurementSummary).orderNo" :status="(row as ProcurementSummary).status" :can-retry="false" compact /><span v-else-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 :disabled="loading" @click="loadDetail">刷新</el-button></div></header>
<OrderFundingStatus v-if="detail" kind="procurement" :order-no="detail.order.orderNo" :status="detail.order.status" :can-retry="canCreate" @changed="loadDetail" />
<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="workflow.rejectionReason" title="采购结果驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
</section>
@@ -224,7 +228,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
</div>
</section>
</el-form>
<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>
<footer class="procurement-form-actions"><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" />