Compare commits
2
Commits
c116817b5d
...
e5fe94ab26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5fe94ab26 | ||
|
|
97bc66f28c |
@@ -2,9 +2,9 @@ import { apiRequest } from '@/api/http'
|
||||
export interface BudgetResult { status: string; usable: boolean; amountCny: number | string; message: string; orderNo: string | null }
|
||||
const base = '/merchant/v1/last-leg/order-budgets'
|
||||
// 创建订单时后端在同一事务记录冻结请求,客户端只在详情核对状态和重试明确失败。
|
||||
export function readOrderFunding(kind: 'procurement' | 'trucking', orderNo: string) {
|
||||
export function readOrderFunding(kind: 'procurement' | 'trucking' | 'looseboard', orderNo: string) {
|
||||
return apiRequest<BudgetResult>({ method: 'GET', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) })
|
||||
}
|
||||
export function retryOrderFunding(kind: 'procurement' | 'trucking', orderNo: string) {
|
||||
export function retryOrderFunding(kind: 'procurement' | 'trucking' | 'looseboard', orderNo: string) {
|
||||
return apiRequest<BudgetResult>({ method: 'POST', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) + '/retry' })
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ test('all current truck statuses display Chinese labels in lists and details', (
|
||||
const { poolOrderStatusLabels } = runInNewContext(`${labelsCode}\n({ poolOrderStatusLabels })`)
|
||||
const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey('))
|
||||
const compiled = ts.transpileModule(helpers, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const { statusLabel, displayCell } = runInNewContext(`${compiled}\n({ statusLabel, displayCell })`, { poolOrderStatusLabels })
|
||||
const { statusLabel, displayCell } = runInNewContext(`${compiled}\n({ statusLabel, displayCell })`, { poolOrderStatusLabels, props: { kind: 'trucking' } })
|
||||
for (const status of ['PENDING_REVIEW', 'FUNDING_PENDING', 'FUNDING_FAILED', 'WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH', 'DISPATCHED', 'IN_TRANSIT', 'COMPLETED', 'REJECTED', 'CANCELLED']) {
|
||||
assert.match(statusLabel(status), /[\u4e00-\u9fff]/, status)
|
||||
assert.equal(displayCell({ status }, 'status'), statusLabel(status))
|
||||
@@ -29,7 +29,7 @@ test('all current truck statuses display Chinese labels in lists and details', (
|
||||
})
|
||||
|
||||
test('truck search excludes warehouse and dispatch preparation stages', () => {
|
||||
const truckingOptions = view.split("options: props.kind === 'trucking' ? [")[1].split('] : [')[0]
|
||||
const truckingOptions = view.split("options: props.kind === 'trucking' ? [")[1].split('] : Object.entries')[0]
|
||||
for (const status of ['WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH']) {
|
||||
assert.ok(!truckingOptions.includes(status), status)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ function transportHarness(kind = 'trucking', failFirst = false, budgetCheck = as
|
||||
const script = view.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
const compiled = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
|
||||
.replace(/^import .*?;$/gm, '').replace(/^export \{\};$/gm, '')
|
||||
const harness = runInNewContext(`${compiled}\n({ form, formRef, submit, detail, detailFields, configuration })`, {
|
||||
const harness = runInNewContext(`${compiled}\n({ form, formRef, submit, detail, detailStatus, detailFields, configuration, queryFields, statusLabel, displayCell })`, {
|
||||
defineProps: () => ({ kind, createPage: true, detailPage: false }),
|
||||
withDefaults: props => props,
|
||||
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
|
||||
@@ -164,6 +164,23 @@ function transportHarness(kind = 'trucking', failFirst = false, budgetCheck = as
|
||||
return { ...harness, calls, rules: { get value() { return demandHelpers.transportDemandRules(kind === 'trucking' ? 'truck' : 'looseboard', harness.form) } } }
|
||||
}
|
||||
|
||||
test('looseboard dropdown and displayed labels use only the actual review and transport workflow', () => {
|
||||
const current = transportHarness('looseboard')
|
||||
const options = current.queryFields.value.find(field => field.key === 'status').options
|
||||
assert.deepEqual(Array.from(options, item => [item.value, item.label]), [
|
||||
['FUNDING_PENDING', '订单受理中'], ['FUNDING_FAILED', '订单处理失败'],
|
||||
['PENDING_ACCEPTANCE', '待审核'], ['PENDING_DISPATCH', '待排车'],
|
||||
['DISPATCHED', '待取货'], ['PICKING_UP', '取货中'], ['IN_TRANSIT', '派送中'],
|
||||
['COMPLETED', '已完成'], ['CANCELLED', '已取消'],
|
||||
])
|
||||
for (const item of options) {
|
||||
assert.equal(current.statusLabel(item.value), item.label)
|
||||
assert.equal(current.displayCell({ status: item.value }, 'status'), item.label)
|
||||
current.detail.value = { summary: { status: item.value } }
|
||||
assert.equal(current.detailStatus.value, item.label)
|
||||
}
|
||||
})
|
||||
|
||||
test('truck requires both contacts vehicle and minute times without inheriting looseboard fields', () => {
|
||||
const truck = transportHarness()
|
||||
for (const key of ['vehicleType', 'originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(truck.rules.value[key][0].required, true)
|
||||
@@ -217,7 +234,7 @@ test('truck details render saved minute times and unknown pool demand without ti
|
||||
|
||||
test('looseboard saves split contacts and both local minute times without obsolete fields', async () => {
|
||||
const current = transportHarness('looseboard', true)
|
||||
Object.assign(current.form, { originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, origin: 'LA', destination: 'ONT', pickupAt: '2026-10-20T09:00:00', deliveryAt: '2026-10-20T15:30:00' })
|
||||
Object.assign(current.form, { originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, budgetAmount: 500, origin: 'LA', destination: 'ONT', pickupAt: '2026-10-20T09:00:00', deliveryAt: '2026-10-20T15:30:00' })
|
||||
await current.submit(); await current.submit()
|
||||
const payload = current.calls[1].payload
|
||||
assert.equal(payload.estimatedPalletCount, 3)
|
||||
|
||||
@@ -70,7 +70,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: 'failureReason', label: looseboard ? '处理失败原因' : '失败 / 拒绝原因', width: 240 },
|
||||
...(looseboard
|
||||
? [{ key: 'fleetArrangement', label: '承运安排', width: 190 }]
|
||||
: [{ key: 'createdAt', label: '创建时间', width: 180 }]),
|
||||
@@ -90,6 +90,18 @@ const formRef = ref<FormInstance>()
|
||||
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' })
|
||||
const total = ref(0)
|
||||
|
||||
const looseboardStatusLabels: Record<string, string> = {
|
||||
FUNDING_PENDING: '订单受理中',
|
||||
FUNDING_FAILED: '订单处理失败',
|
||||
PENDING_ACCEPTANCE: '待审核',
|
||||
PENDING_DISPATCH: '待排车',
|
||||
DISPATCHED: '待取货',
|
||||
PICKING_UP: '取货中',
|
||||
IN_TRANSIT: '派送中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
const queryModel = computed<QueryModel>({
|
||||
get: () => ({
|
||||
orderNo: query.orderNo,
|
||||
@@ -120,19 +132,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
{ label: '已拒绝', value: 'REJECTED' },
|
||||
{ label: '已取消', value: 'CANCELLED' },
|
||||
] : [
|
||||
{ label: '订单受理中', value: 'FUNDING_PENDING' },
|
||||
{ label: '订单处理失败', value: 'FUNDING_FAILED' },
|
||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||
{ label: '已通过', value: 'APPROVED' },
|
||||
{ label: '已拒绝', value: 'REJECTED' },
|
||||
{ label: '待接单', value: 'PENDING_ACCEPTANCE' },
|
||||
...Object.entries(poolOrderStatusLabels).filter(([value]) => !['IN_TRANSIT', 'COMPLETED', 'CANCELLED'].includes(value)).map(([value, label]) => ({ value, label })),
|
||||
{ label: '处理中', value: 'PROCESSING' },
|
||||
{ label: '派送中', value: 'IN_TRANSIT' },
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
{ label: '已取消', value: 'CANCELLED' },
|
||||
],
|
||||
] : Object.entries(looseboardStatusLabels).map(([value, label]) => ({ value, label })),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -200,11 +200,12 @@ function vehicleTypeLabel(value: unknown) { return ({ PALLET_15: '15拖', PALLET
|
||||
function localMinute(value: unknown) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
|
||||
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 (props.kind === 'looseboard') return looseboardStatusLabels[String(value)] || text(value)
|
||||
if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(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', 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'
|
||||
return ({ APPROVED: 'success', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info', FUNDING_PENDING: 'warning', FUNDING_FAILED: 'danger', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PENDING_DISPATCH: 'warning', DISPATCHED: 'primary', PICKING_UP: 'primary', 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
|
||||
@@ -340,7 +341,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 #failureReason="{ row }"><OrderFundingStatus v-if="(row as BusinessRow).status === 'FUNDING_FAILED' || (kind === 'trucking' && (row as BusinessRow).status === 'REJECTED')" :kind="kind" :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>
|
||||
@@ -351,7 +352,7 @@ 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>
|
||||
<div class="business-detail-heading-actions"><el-button v-if="canCancelTruck" type="danger" :loading="submitting" :disabled="loading" @click="cancelTruck">取消订单</el-button><el-tag :type="statusTone(detail?.summary.status)" effect="light">{{ detailStatus }}</el-tag><el-button :disabled="loading" @click="loadDetail">刷新</el-button></div>
|
||||
</header>
|
||||
<OrderFundingStatus v-if="kind === 'trucking' && detail" kind="trucking" :order-no="detail.summary.orderNo" :status="detail.summary.status" :can-retry="canCreate" @changed="loadDetail" />
|
||||
<OrderFundingStatus v-if="detail" :kind="kind" :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>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 props = defineProps<{ kind: 'procurement' | 'trucking' | 'looseboard'; 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
|
||||
|
||||
@@ -39,6 +39,19 @@ test('truck rejection uses latest review reason and never customer remarks', asy
|
||||
assert.equal(missing.message.value, '未填写拒绝原因')
|
||||
})
|
||||
|
||||
test('looseboard funding failures show the server reason and retry the original order', async () => {
|
||||
const calls = []
|
||||
const h = harness({ status: 'FUNDING_FAILED', read: async (kind, no) => {
|
||||
calls.push([kind, no]); return { status: 'FAILED', message: '可用余额和授信额度不足' }
|
||||
}, retry: async (kind, no) => { calls.push([kind, no]); return { status: 'FREEZE_PENDING' } } })
|
||||
h.props.kind = 'looseboard'; h.props.orderNo = 'LB1'
|
||||
await h.check(0, 0)
|
||||
assert.equal(h.message.value, '可用余额和授信额度不足')
|
||||
await h.retry()
|
||||
assert.deepEqual(calls, [['looseboard', 'LB1'], ['looseboard', 'LB1']])
|
||||
assert.equal(h.events.join(','), 'changed')
|
||||
})
|
||||
|
||||
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' } } })
|
||||
|
||||
@@ -323,7 +323,7 @@ test('a lost create response recovers the original order after review releases i
|
||||
})
|
||||
|
||||
test('funding APIs preserve server results and retry the same order only when requested', async () => {
|
||||
for (const kind of ['procurement', 'trucking']) for (const status of ['FREEZE_PENDING', 'FROZEN', 'RELEASE_PENDING', 'RELEASED', 'RELEASE_FAILED', 'FAILED']) {
|
||||
for (const kind of ['procurement', 'trucking', 'looseboard']) for (const status of ['FREEZE_PENDING', 'FROZEN', 'RELEASE_PENDING', 'RELEASED', 'RELEASE_FAILED', 'FAILED']) {
|
||||
const calls = []
|
||||
const result = { status, usable: false, orderNo: 'CG/EXISTING', message: '资金校验失败' }
|
||||
const budgets = moduleFrom(readFileSync(new URL('./api/orderBudgets.ts', import.meta.url), 'utf8'), name => {
|
||||
|
||||
@@ -35,6 +35,8 @@ test('initial form leaves contacts and times blank, zeroes cargo and distinguish
|
||||
assert.equal(form.currency, 'USD')
|
||||
assert.throws(() => buildTransportDemand('truck', { ...valid(), budgetAmount: undefined }), /预算/)
|
||||
assert.throws(() => buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }), /预算/)
|
||||
assert.throws(() => buildTransportDemand('looseboard', { ...valid(), budgetAmount: undefined }), /预算/)
|
||||
assert.equal(buildTransportDemand('looseboard', { ...valid(), budgetAmount: 0 }).budgetAmount, 0)
|
||||
})
|
||||
|
||||
for (const kind of ['truck', 'looseboard']) {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
|
||||
if (field === 'packageCount' && kind === 'truck') continue
|
||||
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
|
||||
}
|
||||
rules.budgetAmount = [{ required: kind === 'truck', type: 'number', min: kind === 'truck' ? 0.01 : 0, message: '请输入大于 0 的预算总价', trigger: 'change' }]
|
||||
rules.budgetAmount = [{ required: true, type: 'number', min: kind === 'truck' ? 0.01 : 0, message: kind === 'truck' ? '请输入大于 0 的预算总价' : '请输入预算总价,不能小于 0', trigger: 'change' }]
|
||||
const pickupLabel = kind === 'truck' ? '取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? '送达时间' : '送货时间'
|
||||
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }]
|
||||
rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, {
|
||||
@@ -110,6 +110,7 @@ export function transportDemandError(kind: TransportDemandKind, form: TransportD
|
||||
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
|
||||
if (kind === 'truck' && (!Number.isSafeInteger(form.estimatedPalletCount) || form.estimatedPalletCount <= 0)) return '请输入大于 0 的托数'
|
||||
if (kind === 'truck' && (form.budgetAmount == null || !Number.isFinite(form.budgetAmount) || form.budgetAmount <= 0)) return '请输入大于 0 的预算总价'
|
||||
if (kind === 'looseboard' && form.budgetAmount == null) return '请输入预算总价'
|
||||
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
|
||||
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
|
||||
return undefined
|
||||
|
||||
Reference in New Issue
Block a user