恢复账单明细入口并完善采购状态展示

This commit is contained in:
2026-10-30 18:50:56 +08:00
parent 119b4d6d66
commit 63193d04de
6 changed files with 52 additions and 12 deletions
@@ -126,7 +126,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 })`, {
const harness = runInNewContext(`${compiled}\n({ form, formRef, submit, detail, detailFields, configuration })`, {
defineProps: () => ({ kind, createPage: true, detailPage: false }),
withDefaults: props => props,
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
@@ -176,13 +176,18 @@ test('truck retry submits the complete new contract with identical local times a
test('truck details render saved minute times and unknown pool demand without timezone conversion', () => {
const current = transportHarness()
current.detail.value = { summary: { vehicleType: null, pickupAt: '2026-10-20T09:30:00', deliveryAt: null }, originAddress: null, destinationAddress: null, remark: null }
current.detail.value = { summary: { vehicleType: null, palletCount: 3, packageCount: 0, pickupAt: '2026-10-20T09:30:00', deliveryAt: null }, originAddress: null, destinationAddress: null, remark: null }
const fields = new Map(current.detailFields.value)
assert.equal(fields.get('车辆类型'), '—')
assert.equal(fields.get('取货时间'), '2026-10-20 09:30')
assert.equal(fields.get('送达时间'), '—')
assert.equal(fields.has('柜型'), false)
assert.equal(fields.has('托盘数'), false)
assert.equal(fields.get('托数'), 3)
assert.equal(fields.has('件数'), false)
const columns = current.configuration.value.columns
assert.equal(columns.find(column => column.label === '托数').key, 'palletCount')
assert.equal(columns.some(column => column.key === 'packageCount'), false)
})
@@ -199,6 +204,12 @@ test('looseboard saves split contacts and both local minute times without obsole
assert.equal(payload.destinationContactPhone, '2')
assert.equal(payload.deliveryAt, '2026-10-20T15:30:00')
assert.equal(payload.idempotencyKey, current.calls[0].payload.idempotencyKey)
current.detail.value = { summary: { ...payload }, originAddress: null, destinationAddress: null, remark: null }
const fields = new Map(current.detailFields.value)
assert.equal(fields.get('件数'), 8)
assert.equal(fields.get('预计托数'), 3)
assert.equal(fields.has('托数'), false)
assert.equal(current.configuration.value.columns.some(column => column.key === 'palletCount'), false)
for (const field of ['palletRequired', 'transportType', 'transportMode', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, field), false)
})
@@ -61,6 +61,7 @@ function transportColumns(looseboard: boolean): ColumnDefinition[] {
{ key: 'origin', label: originLabel, width: 150 },
{ key: 'destination', label: destinationLabel, width: 150 },
...(looseboard ? [{ key: 'productName', label: '商品名称', width: 150 }, { key: 'estimatedPalletCount', label: '预计托数', width: 100 }] : []),
...(!looseboard ? [{ key: 'palletCount', label: '托数', width: 80 }] : []),
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
{ key: 'budgetAmount', label: '预算总价', width: 110 },
...([
@@ -136,7 +137,7 @@ const detailFields = computed(() => {
] : [['车辆类型', vehicleTypeLabel(summary.vehicleType)]]),
[props.kind === 'trucking' ? '取货地' : '起运地', read(summary, 'origin')], [props.kind === 'trucking' ? '取货地址' : '起运地址', current.originAddress],
[props.kind === 'trucking' ? '送达地' : '目的地', read(summary, 'destination')], [props.kind === 'trucking' ? '送达地址' : '目的地址', current.destinationAddress],
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
[props.kind === 'trucking' ? '托数' : '件数', read(summary, props.kind === 'trucking' ? 'palletCount' : 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
[props.kind === 'trucking' ? '取货联系人' : '起运地联系人', summary.originContactName], [props.kind === 'trucking' ? '取货联系电话' : '起运地联系电话', summary.originContactPhone],
[props.kind === 'trucking' ? '送达联系人' : '目的地联系人', summary.destinationContactName], [props.kind === 'trucking' ? '送达联系电话' : '目的地联系电话', summary.destinationContactPhone],
@@ -294,7 +295,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<h2>{{ configuration.title }}下单</h2>
</div>
<el-button @click="returnToList">返回列表</el-button>
<el-button v-if="kind === 'looseboard'" @click="returnToList">返回列表</el-button>
</header>
<template v-if="mode === 'list'">
@@ -6,6 +6,7 @@ import { runInNewContext } from 'node:vm'
import ts from 'typescript'
import * as vue from 'vue'
import { parse, compileScript } from 'vue/compiler-sfc'
import { renderToString } from 'vue/server-renderer'
const { default: Schema } = createRequire(import.meta.resolve('element-plus'))('async-validator')
@@ -63,6 +64,27 @@ test('new requests get unique keys without restoring stored submissions', () =>
class ApiError extends Error { constructor(message, code, status) { super(message); this.code = code; this.status = status } }
const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
test('shows only the active procurement rejection, hiding it after reupload, resubmission and confirmation', async () => {
const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
const template = source.match(/<el-alert\b[^>]*title="采购结果驳回原因"[^>]*\/>/)?.[0]
assert.ok(template)
for (const [status, photosNeedUpdate, rejectionReason, visible] of [
['RECEIVING', true, '照片不清晰', true],
['RECEIVING', false, '照片不清晰', true],
['RECEIVING', false, null, false],
['WAIT_CUSTOMER_CONFIRM', true, '照片不清晰', false],
['CONFIRMED', true, '照片不清晰', false],
['RECEIVING', true, null, false],
]) {
const app = vue.createSSRApp({ render: vue.compile(template), setup: () => ({ workflow: { status, photosNeedUpdate, rejectionReason } }) })
app.component('ElAlert', { props: ['title', 'description'], setup: props => () => vue.h('aside', `${props.title} ${props.description}`) })
const html = await renderToString(app)
assert.equal(html.includes('采购结果驳回原因'), visible, `${status}, photosNeedUpdate=${photosNeedUpdate}`)
assert.equal(html.includes('照片不清晰'), visible)
}
})
function viewHarness({ createPage = false, detailPage = false, api = {}, storage = new Map() } = {}) {
const props = vue.reactive({ createPage, detailPage })
const route = vue.reactive({ params: { orderNo: detailPage ? 'PO-1' : '' } })
@@ -164,7 +164,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<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 />
<template v-else-if="detail && workflow"><el-alert v-if="workflow.status === 'RECEIVING' && 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">
@@ -55,12 +55,18 @@ test('adapts the Finance Java DTO contract and filters nested receipt allocation
assert.doesNotMatch(adapterSource, /bill_no|customer_name|bill_date|total_amount|outstanding_amount|receipt_no|bill_id|applied_amount/)
assert.doesNotMatch(adapterSource, /receipt\.billId|receipt\.appliedAmount/)
assert.doesNotMatch(adapterSource, /voucherNo|voucher_no/)
assert.match(viewSource, /name: 'finance-bill-detail', params: \{ billNo: row\.billNo \}/)
assert.doesNotMatch(viewSource, /key: 'detail'|label: '查看'/)
assert.match(detailViewSource, /getMerchantFinanceBill\(summary\.id\)/)
assert.match(detailViewSource, /getMerchantFinanceReceipts\(summary\.id\)/)
assert.doesNotMatch(detailViewSource, /getMerchantFinanceBill\(billNo\.value\)/)
})
test('keeps bill numbers linked to business-number details independently of confirmation actions', () => {
assert.match(viewSource, /label: '账单编号',\s*prop: 'billNo',\s*slotName: 'billNo'/)
assert.match(viewSource, /<template #billNo="\{ row \}">\s*<RouterLink[^>]*:to="\{ name: 'finance-bill-detail', params: \{ billNo: row\.billNo \} \}"[^>]*>\{\{ row\.billNo \}\}<\/RouterLink>\s*<\/template>/)
assert.doesNotMatch(viewSource.match(/<template #billNo[^]*?<\/template>/)?.[0] || '', /canConfirm|confirmationStatus|row\.id/)
})
test('does not expose draft or voided bills returned by the server', () => {
const bill = (id, status) => ({
id,
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { RefreshCcw } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { RouterLink } from 'vue-router'
import { ApiError } from '@/api/http'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
@@ -13,7 +13,6 @@ import type { FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCu
const loading = ref(false)
const auth = useAuthStore()
const router = useRouter()
const errorMessage = ref('')
const bills = ref<FinanceBillSummary[]>([])
const confirmingBillId = ref('')
@@ -38,7 +37,7 @@ const queryFields: QueryField[] = [
]
const tableColumns: DataTableColumn[] = [
{ label: '操作', slotName: 'actions', width: 190, fixed: 'left' },
{ label: '账单编号', prop: 'billNo', minWidth: 160 },
{ label: '账单编号', prop: 'billNo', slotName: 'billNo', minWidth: 160 },
{ label: '出账日期', slotName: 'issueDate', width: 120 },
{ label: '到期日', slotName: 'dueDate', width: 120 },
{ label: '账单金额', slotName: 'totalAmount', width: 145, align: 'right' },
@@ -104,11 +103,9 @@ async function loadBills(nextPage = page.value) {
}
}
function openDetail(row: FinanceBillSummary) { void router.push({ name: 'finance-bill-detail', params: { billNo: row.billNo } }) }
function rowActions(row: object): TableAction[] {
const statement = row as FinanceBillSummary
const actions: TableAction[] = [{ key: 'detail', label: '查看', run: () => openDetail(statement) }]
const actions: TableAction[] = []
if (statement.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm.value) {
actions.push({
key: 'confirm',
@@ -181,6 +178,7 @@ onMounted(() => { void loadBills(1) })
<section class="content-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="bills" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" row-key="billNo" empty-text="暂无账单">
<template v-if="errorMessage" #error><div class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button></div></template>
<template #billNo="{ row }"><RouterLink class="bill-number-link" :to="{ name: 'finance-bill-detail', params: { billNo: row.billNo } }">{{ row.billNo }}</RouterLink></template>
<template #issueDate="{ row }">{{ formatDate(row.issueDate) }}</template>
<template #dueDate="{ row }">{{ formatDate(row.dueDate) }}</template>
<template #totalAmount="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template>
@@ -196,6 +194,8 @@ onMounted(() => { void loadBills(1) })
<style scoped>
.finance-bills-page { display: flex; flex-direction: column; gap: 12px; }
.bill-number-link { color: var(--primary); text-decoration: none; }
.bill-number-link:hover { text-decoration: underline; }
.finance-summary { display: flex; flex-wrap: wrap; gap: 8px 26px; color: var(--muted); font-size: 13px; }.finance-summary strong { margin-left: 6px; color: var(--navy); font-size: 18px; }.amount-warning, .amount-warning strong { color: #b45309; }
.content-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: var(--surface, #fff); }
.state-panel { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 12px; flex-direction: column; color: var(--muted); text-align: center; }.state-panel p { margin: 0; }.state-panel--error { color: #b42318; background: #fff8f7; border: 1px solid #f5c2bd; border-radius: 6px; }