Compare commits
2
Commits
8dd7b16d1d
...
b977ccd89d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b977ccd89d | ||
|
|
f51e51d50f |
@@ -32,3 +32,6 @@
|
||||
| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 |
|
||||
|
||||
统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。
|
||||
|
||||
|
||||
2026-10-29 用户截图调整:采购待确认支持确认或必填原因驳回;四张实收/库存数量卡片置顶,商品及收件信息置底。本土卡车数量改托数,托数及预算须为正数。采购及卡车先等待Finance余额/授信冻结成功,再以同一幂等键建单;不足保留表单并提示原因。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface TruckCreate {
|
||||
destinationContactPhone: string
|
||||
pickupAt: string
|
||||
deliveryAt: string
|
||||
packageCount: number
|
||||
palletCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount?: number
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
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 || '余额及授信校验未完成,请稍后重试')
|
||||
}
|
||||
@@ -118,6 +118,8 @@ export interface ProcurementWorkflow {
|
||||
orderNo: string
|
||||
version: number
|
||||
status: string
|
||||
rejectionReason: string | null
|
||||
photosNeedUpdate: boolean
|
||||
receivedQuantity: number
|
||||
stockQuantity: number
|
||||
reservedQuantity: number
|
||||
@@ -144,6 +146,9 @@ export function confirmProcurement(orderNo: string, data: { version: number; ide
|
||||
return apiRequest<ProcurementWorkflow>({ method: 'POST', url: `${endpoint}/${encodeURIComponent(orderNo)}/confirm`, data })
|
||||
}
|
||||
|
||||
export function rejectProcurement(orderNo: string, data: { version: number; idempotencyKey: string; reason: string }) {
|
||||
return apiRequest<ProcurementWorkflow>({ method: 'POST', url: `${endpoint}/${encodeURIComponent(orderNo)}/reject`, data })
|
||||
}
|
||||
export function getProcurementFile(orderNo: string, fileId: string) {
|
||||
return apiBlobRequest({ method: 'GET', url: `${endpoint}/${encodeURIComponent(orderNo)}/files/${encodeURIComponent(fileId)}/content` })
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ test('uses the shared dense order-list and detail visual language', () => {
|
||||
assert.match(view, /\.detail-grid div \{[^}]*min-height: 34px;[^}]*grid-template-columns: 76px minmax\(0, 1fr\)/)
|
||||
})
|
||||
|
||||
function transportHarness(kind = 'trucking', failFirst = false) {
|
||||
function transportHarness(kind = 'trucking', failFirst = false, budgetCheck = async () => {}) {
|
||||
const calls = []
|
||||
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
|
||||
@@ -130,7 +130,7 @@ function transportHarness(kind = 'trucking', failFirst = false) {
|
||||
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
|
||||
reactive: value => value, ref: value => ({ value }), onMounted: () => {}, watch: () => {},
|
||||
useAuthStore: () => ({ hasPermission: () => true }), useRouter: () => ({ push: async () => {} }), useRoute: () => ({ params: {} }),
|
||||
...demandHelpers, poolOrderStatusLabels: {},
|
||||
...demandHelpers, poolOrderStatusLabels: {}, requireOrderBudget: budgetCheck,
|
||||
ElMessage: { success: () => {}, error: () => {} },
|
||||
createTransport: async (business, payload) => { calls.push({ business, payload: { ...payload } }); if (failFirst && calls.length === 1) throw new Error('retry'); },
|
||||
})
|
||||
@@ -155,7 +155,7 @@ test('truck requires both contacts vehicle and minute times without inheriting l
|
||||
|
||||
test('truck retry submits the complete new contract with identical local times and idempotency key', async () => {
|
||||
const current = transportHarness('trucking', true)
|
||||
Object.assign(current.form, { vehicleType: 'PALLET_30', origin: 'Los Angeles', destination: 'Ontario', originContactName: 'A', originContactPhone: '+1-555-0100', destinationContactName: 'B', destinationContactPhone: '+1-555-0101', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' })
|
||||
Object.assign(current.form, { vehicleType: 'PALLET_30', estimatedPalletCount: 3, budgetAmount: 100, origin: 'Los Angeles', destination: 'Ontario', originContactName: 'A', originContactPhone: '+1-555-0100', destinationContactName: 'B', destinationContactPhone: '+1-555-0101', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' })
|
||||
await current.submit()
|
||||
assert.equal(current.form.destinationContactName, 'B')
|
||||
await current.submit()
|
||||
@@ -164,11 +164,12 @@ test('truck retry submits the complete new contract with identical local times a
|
||||
const second = current.calls[1].payload
|
||||
assert.equal(first.idempotencyKey, second.idempotencyKey)
|
||||
assert.equal(second.vehicleType, 'PALLET_30')
|
||||
assert.equal(second.palletCount, 3)
|
||||
assert.equal(second.originContactPhone, '+1-555-0100')
|
||||
assert.equal(second.destinationContactName, 'B')
|
||||
assert.equal(second.pickupAt, '2026-10-20T09:30:00')
|
||||
assert.equal(second.deliveryAt, '2026-10-20T15:45:00')
|
||||
for (const field of ['transportType', 'containerType', 'palletCount', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(second, field), false)
|
||||
for (const field of ['transportType', 'containerType', 'packageCount', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(second, field), false)
|
||||
})
|
||||
|
||||
test('truck details render saved minute times and unknown pool demand without timezone conversion', () => {
|
||||
@@ -208,3 +209,17 @@ test('looseboard requires both times and rejects backwards schedule', () => {
|
||||
current.rules.value.deliveryAt[1].validator({}, '2026-10-20T10:29:00', error => { failure = error })
|
||||
assert.ok(failure)
|
||||
})
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -3,6 +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 { 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'
|
||||
@@ -118,6 +119,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
|
||||
const form = reactive<BusinessForm>(emptyForm())
|
||||
watch(() => JSON.stringify(form), () => { submitKey.value = '' })
|
||||
|
||||
const detailFields = computed(() => {
|
||||
if (!detail.value) return []
|
||||
@@ -252,6 +254,7 @@ async function loadDetail() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const failure = transportDemandError(props.kind === 'trucking' ? 'truck' : 'looseboard', form)
|
||||
if (failure) { ElMessage.error(failure); return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
@@ -259,13 +262,17 @@ async function submit() {
|
||||
const stableSubmitKey = submitKey.value || idempotencyKey()
|
||||
submitKey.value = stableSubmitKey
|
||||
try {
|
||||
if (props.kind === 'trucking') await createTransport('trucking', { ...buildTransportDemand('truck', form), idempotencyKey: stableSubmitKey })
|
||||
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('订单提交成功')
|
||||
resetForm()
|
||||
if (props.createPage) await router.push({ name: listRouteName.value })
|
||||
else { mode.value = 'list'; await load() }
|
||||
} catch (cause) { ElMessage.error(errorMessage(cause)) }
|
||||
} catch (cause) { if (/已释放|已过期/.test(errorMessage(cause))) submitKey.value = ''; ElMessage.error(errorMessage(cause)) }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -25,31 +25,17 @@ const totals = computed(() => feeTotals(props.detail.fees))
|
||||
|
||||
<template>
|
||||
<el-alert v-if="fileError" :title="fileError" type="error" :closable="false" />
|
||||
<section class="procurement-section" aria-label="商品与收件信息">
|
||||
<h3>商品与收件信息</h3>
|
||||
<dl class="procurement-grid">
|
||||
<div><dt>采购方向</dt><dd>{{ directionLabel(detail.order.procurementDirection) }}</dd></div>
|
||||
<div><dt>商品名称</dt><dd>{{ detail.order.productName }}</dd></div>
|
||||
<div><dt>商品规格</dt><dd>{{ text(detail.order.productSpecification) }}</dd></div>
|
||||
<div><dt>采购品类</dt><dd>{{ text(detail.order.purchaseCategory) }}</dd></div>
|
||||
<div><dt>采购数量</dt><dd>{{ detail.order.purchaseQuantity }} 件</dd></div>
|
||||
<div><dt>预算单价</dt><dd>{{ money(detail.order.budgetUnitPrice, detail.order.currency) }}</dd></div>
|
||||
<div><dt>预算合计</dt><dd>{{ money(detail.budget.totalAmount, detail.budget.currency) }}</dd></div>
|
||||
<div><dt>采购仓</dt><dd>{{ detail.order.actualWarehouseName || detail.order.plannedWarehouseName || '待审核指定' }}<span v-if="detail.order.actualWarehouseCode">({{ detail.order.actualWarehouseCode }})</span></dd></div>
|
||||
<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.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>
|
||||
<section class="procurement-section" aria-label="分批收货">
|
||||
<h3>收货记录</h3>
|
||||
<section class="procurement-section" aria-label="采购数量统计">
|
||||
<dl class="procurement-grid money-grid">
|
||||
<div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }} 件</dd></div>
|
||||
<div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }} 件</dd></div>
|
||||
<div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }} 件</dd></div>
|
||||
<div><dt>可出库</dt><dd>{{ workflow.availableQuantity }} 件</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="procurement-section" aria-label="分批收货">
|
||||
<h3>收货记录</h3>
|
||||
|
||||
<el-table :data="workflow.receipts" border stripe empty-text="暂无收货记录" row-key="id">
|
||||
<el-table-column label="批次" width="80" align="center"><template #default="{ $index }">{{ $index + 1 }}</template></el-table-column>
|
||||
<el-table-column prop="quantity" label="收货数量(件)" width="145" align="right" />
|
||||
@@ -78,6 +64,23 @@ const totals = computed(() => feeTotals(props.detail.fees))
|
||||
<el-table-column label="扣款结果" min-width="160" align="center"><template #default="{ row }"><el-tag :type="feeTone(row as ProcurementFee)" size="small">{{ feeStatus(row as ProcurementFee) }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<section class="procurement-section" aria-label="商品与收件信息">
|
||||
<h3>商品与收件信息</h3>
|
||||
<dl class="procurement-grid">
|
||||
<div><dt>采购方向</dt><dd>{{ directionLabel(detail.order.procurementDirection) }}</dd></div>
|
||||
<div><dt>商品名称</dt><dd>{{ detail.order.productName }}</dd></div>
|
||||
<div><dt>商品规格</dt><dd>{{ text(detail.order.productSpecification) }}</dd></div>
|
||||
<div><dt>采购品类</dt><dd>{{ text(detail.order.purchaseCategory) }}</dd></div>
|
||||
<div><dt>采购数量</dt><dd>{{ detail.order.purchaseQuantity }} 件</dd></div>
|
||||
<div><dt>预算单价</dt><dd>{{ money(detail.order.budgetUnitPrice, detail.order.currency) }}</dd></div>
|
||||
<div><dt>预算合计</dt><dd>{{ money(detail.budget.totalAmount, detail.budget.currency) }}</dd></div>
|
||||
<div><dt>采购仓</dt><dd>{{ detail.order.actualWarehouseName || detail.order.plannedWarehouseName || '待审核指定' }}<span v-if="detail.order.actualWarehouseCode">({{ detail.order.actualWarehouseCode }})</span></dd></div>
|
||||
<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.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>
|
||||
<el-dialog :model-value="!!previewUrl" :title="previewName" width="800px" append-to-body @close="clearPreview">
|
||||
<img v-if="previewImage" :src="previewUrl" :alt="previewName" style="display: block; max-width: 100%; max-height: 70vh; margin: auto" />
|
||||
<a v-else :href="previewUrl" :download="previewName">下载 {{ previewName }}</a>
|
||||
@@ -91,7 +94,7 @@ h3 { margin: 0 0 10px; padding-bottom: 7px; border-bottom: 1px solid var(--borde
|
||||
.procurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); border-top: 1px solid var(--border); }
|
||||
.procurement-grid > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); align-items: center; gap: 8px; padding: 9px 12px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); min-width: 0; }
|
||||
.procurement-grid dt { font-size: 12px; color: var(--muted); }.procurement-grid dd { margin: 0; font-size: 13px; line-height: 1.6; color: var(--navy); overflow-wrap: anywhere; }
|
||||
.procurement-grid .wide { grid-column: span 2; }.money-grid > div { grid-template-columns: minmax(0, 1fr); }.money-grid dd { font-size: 16px; font-weight: 600; }
|
||||
.procurement-grid .wide { grid-column: span 2; }.money-grid { gap: 16px; border: 0; }.money-grid > div { grid-template-columns: minmax(0, 1fr); padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: #f8fbff; }.money-grid dd { font-size: 16px; font-weight: 600; }
|
||||
.procurement-grid .full { grid-column: 1 / -1; }
|
||||
.procurement-section :deep(.el-table .cell) { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.procurement-section :deep(.el-table .el-button.is-link) { display: flex; justify-content: flex-start; max-width: 100%; height: auto; margin: 0; padding: 3px 0; white-space: normal; text-align: left; }
|
||||
|
||||
@@ -44,7 +44,7 @@ defineExpose({
|
||||
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
|
||||
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" placeholder="" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" placeholder="" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck'" label="托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="1" :precision="0" controls-position="right" placeholder="" /></el-form-item>
|
||||
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" placeholder="" /></el-form-item>
|
||||
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" placeholder="" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="" /></el-form-item>
|
||||
|
||||
@@ -72,10 +72,11 @@ function viewHarness({ createPage = false, detailPage = false, api = {}, storage
|
||||
require(name) {
|
||||
if (name === 'vue') return { ...vue, onBeforeUnmount: callback => dispose.push(callback), watch: (...args) => { const stop = vue.watch(...args); dispose.push(stop); return stop } }
|
||||
if (name === 'vue-router') return { useRoute: () => route, useRouter: () => ({ push: async target => pushes.push(target) }) }
|
||||
if (name === 'element-plus') return { ElMessage: { success: () => {} }, ElMessageBox: { confirm: async () => {} } }
|
||||
if (name === 'element-plus') return { ElMessage: { success: () => {} }, ElMessageBox: { confirm: async () => {}, prompt: async (...args) => { const value = api.rejectionReason ?? '照片不清晰'; const valid = args[2].inputValidator(value); if (valid !== true) throw new Error(valid); return { value } } } }
|
||||
if (name === '@/api/types') return { ApiError }
|
||||
if (name === '@/api/session') return { accessTokenIdentity: token => token }
|
||||
if (name === '@/modules/auth/public') return { useAuthStore: () => auth }
|
||||
if (name === '../api/orderBudgets') return { requireOrderBudget: api.requireOrderBudget || (async () => {}) }
|
||||
if (name === '../api/procurement') return { ...defaults, ...api }
|
||||
if (name === '../procurementPresentation') return presentation
|
||||
if (name === '../procurementSubmission') return submission
|
||||
@@ -265,3 +266,27 @@ test('bill-settled manual fees show review results without counting a balance de
|
||||
assert.equal(presentation.feeTone(bill('REJECTED')), 'danger')
|
||||
assert.equal(presentation.feeTotals([bill('PENDING'), bill('APPROVED'), bill('REJECTED')]).paid, '0.00')
|
||||
})
|
||||
|
||||
|
||||
test('insufficient balance and credit blocks procurement creation and preserves the draft', async () => {
|
||||
let creates = 0
|
||||
const harness = viewHarness({ createPage: true, api: { requireOrderBudget: async () => { throw new Error('余额及授信不足') }, createProcurement: async () => { creates++ } } })
|
||||
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)
|
||||
} 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 } } } })
|
||||
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')
|
||||
} 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() }
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import ts from 'typescript'
|
||||
const source = readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8')
|
||||
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '')
|
||||
const { createTransportDemandForm, transportDemandError, buildTransportDemand } = runInNewContext(`${code}\n({ createTransportDemandForm, transportDemandError, buildTransportDemand })`)
|
||||
const valid = () => ({ ...createTransportDemandForm(), vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' +1-555-0100 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' +1-555-0101 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12' })
|
||||
const valid = () => ({ ...createTransportDemandForm(), vehicleType: 'PALLET_15', estimatedPalletCount: 3, budgetAmount: 100, origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' +1-555-0100 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' +1-555-0101 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12' })
|
||||
|
||||
test('initial form leaves contacts and times blank, zeroes cargo and distinguishes blank budget from zero', () => {
|
||||
const form = createTransportDemandForm()
|
||||
@@ -15,8 +15,8 @@ test('initial form leaves contacts and times blank, zeroes cargo and distinguish
|
||||
for (const key of ['estimatedPalletCount', 'packageCount', 'totalWeightKg', 'totalVolumeCbm']) assert.equal(form[key], 0)
|
||||
assert.equal(form.budgetAmount, undefined)
|
||||
assert.equal(form.currency, 'USD')
|
||||
assert.equal(buildTransportDemand('truck', valid()).budgetAmount, undefined)
|
||||
assert.equal(buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }).budgetAmount, 0)
|
||||
assert.throws(() => buildTransportDemand('truck', { ...valid(), budgetAmount: undefined }), /预算/)
|
||||
assert.throws(() => buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }), /预算/)
|
||||
})
|
||||
|
||||
for (const kind of ['truck', 'looseboard']) {
|
||||
@@ -53,6 +53,8 @@ test('truck accepts only the two vehicle types and does not submit looseboard ca
|
||||
const truck = buildTransportDemand('truck', { ...valid(), productName: 'cargo', estimatedPalletCount: 3 })
|
||||
assert.equal(Object.hasOwn(truck, 'productName'), false)
|
||||
assert.equal(Object.hasOwn(truck, 'estimatedPalletCount'), false)
|
||||
assert.equal(truck.palletCount, 3)
|
||||
assert.equal(Object.hasOwn(truck, 'packageCount'), false)
|
||||
const loose = buildTransportDemand('looseboard', { ...valid(), vehicleType: '', productName: ' cargo ', estimatedPalletCount: 3, packageCount: 8 })
|
||||
assert.equal(Object.hasOwn(loose, 'vehicleType'), false)
|
||||
assert.equal(loose.productName, 'cargo')
|
||||
|
||||
@@ -53,10 +53,10 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
|
||||
}
|
||||
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
|
||||
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
|
||||
if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue
|
||||
if (field === 'packageCount' && kind === 'truck') continue
|
||||
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
|
||||
}
|
||||
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
|
||||
rules.budgetAmount = [{ required: kind === 'truck', type: 'number', min: kind === 'truck' ? 0.01 : 0, message: '请输入大于 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' }, {
|
||||
@@ -81,6 +81,8 @@ export function transportDemandError(kind: TransportDemandKind, form: TransportD
|
||||
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
|
||||
}
|
||||
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 (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
|
||||
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
|
||||
return undefined
|
||||
@@ -99,7 +101,7 @@ function commonPayload(form: TransportDemandForm) {
|
||||
}
|
||||
|
||||
type CommonPayload = ReturnType<typeof commonPayload>
|
||||
type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' }
|
||||
type TruckPayload = Omit<CommonPayload, 'packageCount'> & { palletCount: number; vehicleType: 'PALLET_15' | 'PALLET_30' }
|
||||
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
|
||||
|
||||
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
|
||||
@@ -109,6 +111,6 @@ export function buildTransportDemand(kind: TransportDemandKind, form: TransportD
|
||||
if (failure) throw new Error(failure)
|
||||
const common = commonPayload(form)
|
||||
return kind === 'truck'
|
||||
? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
|
||||
? { ...(({ packageCount: _packages, ...rest }) => rest)(common), palletCount: form.estimatedPalletCount, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
|
||||
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ 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 { confirmProcurement, createProcurement, getProcurement, getProcurementWorkflow, listProcurements, type ProcurementDetail, type ProcurementSummary, type ProcurementWorkflow } from '../api/procurement'
|
||||
import { rejectProcurement, confirmProcurement, 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'
|
||||
|
||||
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
|
||||
@@ -101,6 +102,21 @@ async function confirmReceived() {
|
||||
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()); createAttempt = null }
|
||||
@@ -111,11 +127,13 @@ 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('采购订单已提交')
|
||||
if (canList.value) await router.push({ name: 'cross-border-procurement-detail', params: { orderNo: result.order.orderNo } })
|
||||
} catch (cause) { 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) => {
|
||||
@@ -146,9 +164,9 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
|
||||
</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="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 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>
|
||||
<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" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
|
||||
<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 />
|
||||
</section>
|
||||
<section v-else class="procurement-create" aria-label="采购下单">
|
||||
<header class="procurement-create-heading">
|
||||
|
||||
Reference in New Issue
Block a user