This commit is contained in:
2026-08-19 09:14:38 +08:00
parent eedaa2f080
commit 372ec3dc37
10 changed files with 217 additions and 62 deletions
+9
View File
@@ -2,6 +2,7 @@ import type {
FinanceBillDetail,
FinanceBillLine,
FinanceBillStatus,
FinanceCustomerConfirmationStatus,
FinanceBillSummary,
FinanceReceiptRecord,
} from '../contracts'
@@ -20,6 +21,10 @@ export interface FinanceBillWire {
totalAmount: WireScalar
receivedAmount: WireScalar
outstandingAmount: WireScalar
version: number
customerConfirmationStatus: FinanceCustomerConfirmationStatus
customerConfirmedByName: string | null
customerConfirmedAt: string | null
}
export interface FinanceBillLineWire {
@@ -73,6 +78,10 @@ function mapBillSummary(bill: FinanceBillWire): FinanceBillSummary {
issueDate: bill.billDate,
dueDate: bill.dueDate,
status: bill.status,
version: bill.version ?? 0,
customerConfirmationStatus: bill.customerConfirmationStatus ?? 'NOT_REQUIRED',
customerConfirmedByName: bill.customerConfirmedByName ?? null,
customerConfirmedAt: bill.customerConfirmedAt ?? null,
}
}
+16 -1
View File
@@ -1,5 +1,5 @@
import { apiRequest } from '@/api/http'
import type { FinanceBillDetail, FinanceBillPage, FinanceBillQuery, FinanceReceiptRecord } from '../contracts'
import type { FinanceBillDetail, FinanceBillPage, FinanceBillQuery, FinanceCustomerConfirmationResult, FinanceReceiptRecord } from '../contracts'
import {
mapFinanceBillDetail,
mapFinanceBills,
@@ -54,3 +54,18 @@ export async function getMerchantFinanceReceipts(billId: string): Promise<Financ
})
return mapFinanceReceipts(response, billId)
}
function confirmationIdempotencyKey(statementId: string) {
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `statement-confirm-${statementId}-${randomPart}`
}
export async function confirmMerchantFinanceStatement(statementId: string, expectedVersion: number,
idempotencyKey = confirmationIdempotencyKey(statementId)) {
return apiRequest<FinanceCustomerConfirmationResult>({
method: 'POST',
url: `/client/finance/v1/statements/${encodeURIComponent(statementId)}/confirm`,
headers: { 'Idempotency-Key': idempotencyKey },
data: { expectedVersion },
})
}
+14
View File
@@ -1,6 +1,7 @@
import type { PageResult } from '@/modules/first-leg/api/firstLeg'
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
export type FinanceCustomerConfirmationStatus = 'NOT_REQUIRED' | 'PENDING_CONFIRMATION' | 'CONFIRMED'
export interface FinanceBillQuery {
keyword?: string
@@ -20,6 +21,19 @@ export interface FinanceBillSummary {
issueDate: string | null
dueDate: string | null
status: FinanceBillStatus
version: number
customerConfirmationStatus: FinanceCustomerConfirmationStatus
customerConfirmedByName: string | null
customerConfirmedAt: string | null
}
export interface FinanceCustomerConfirmationResult {
statementId: string
confirmationStatus: 'CONFIRMED'
confirmedBy: string
confirmedByName: string
confirmedAt: string
version: number
}
export interface FinanceBillLine {
@@ -20,7 +20,7 @@ async function importTypeScriptModule(url) {
const financeAdapters = await importTypeScriptModule(new URL('../api/adapters.ts', import.meta.url))
const permissionContracts = await importTypeScriptModule(new URL('../../tenant-management/contracts.ts', import.meta.url))
test('activates the customer finance route with a read-only permission', () => {
test('activates the customer finance route with its existing list and detail permission', () => {
assert.match(routesSource, /path: 'finance\/bills'/)
assert.match(routesSource, /FinanceBillsView\.vue/)
assert.match(routesSource, /release: 'active'/)
@@ -28,13 +28,17 @@ test('activates the customer finance route with a read-only permission', () => {
assert.doesNotMatch(routesSource, /finance\/payables|应付账单/)
})
test('uses client finance read APIs without browser-selected customer scope', () => {
test('uses authenticated-scope finance APIs and confirms without browser-selected customer scope', () => {
assert.match(apiSource, /method: 'GET'/)
assert.match(apiSource, /\/client\/finance\/v1\/bills/)
assert.match(apiSource, /\/client\/finance\/v1\/receipts/)
assert.doesNotMatch(apiSource, /\/merchant\/v1\/finance/)
assert.doesNotMatch(apiSource, /customerId|tenantId/)
assert.doesNotMatch(apiSource, /method: '(POST|PUT|PATCH|DELETE)'/)
assert.match(apiSource, /method: 'POST'/)
assert.match(apiSource, /\/client\/finance\/v1\/statements\/\$\{encodeURIComponent\(statementId\)\}\/confirm/)
assert.match(apiSource, /'Idempotency-Key'/)
assert.match(apiSource, /data: \{ expectedVersion \}/)
assert.doesNotMatch(apiSource, /method: '(PUT|PATCH|DELETE)'/)
assert.doesNotMatch(apiSource, /params:\s*\{\s*billId/)
})
@@ -65,6 +69,10 @@ test('does not expose draft or voided bills returned by the server', () => {
totalAmount: '100.00',
receivedAmount: '0.00',
outstandingAmount: '100.00',
version: 1,
customerConfirmationStatus: 'PENDING_CONFIRMATION',
customerConfirmedByName: null,
customerConfirmedAt: null,
})
const visible = financeAdapters.mapFinanceBills([
bill(1, 'DRAFT'),
@@ -82,6 +90,7 @@ test('maps nested bill detail and customer-safe receipt allocations', () => {
id: 2, customerId: 10001, billNo: 'B-2', customerName: '测试客户', currency: 'CNY',
billDate: '2026-08-11', dueDate: '2026-09-10', status: 'ISSUED', totalAmount: '100.00',
receivedAmount: '40.00', outstandingAmount: '60.00',
version: 3, customerConfirmationStatus: 'PENDING_CONFIRMATION', customerConfirmedByName: null, customerConfirmedAt: null,
},
lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
})
@@ -104,21 +113,21 @@ test('requires both finance statement authorities for the page permission', () =
assert.equal(allowed(['merchant:finance:statement:list', 'merchant:finance:statement:view']), true)
})
test('keeps actions first and exposes list, balance, detail, receipt, empty and error states', () => {
test('keeps actions first and exposes list, balance, detail, confirmation, receipt, empty and error states', () => {
const actionIndex = viewSource.indexOf('label="操作"')
const billNumberIndex = viewSource.indexOf('label="账单编号"')
assert.ok(actionIndex >= 0 && actionIndex < billNumberIndex)
for (const expected of ['未收余额', '账单详情', '收款记录', '暂无账单', '暂无收款记录', 'state-panel--error']) {
for (const expected of ['未收余额', '账单详情', '确认账单', '客户确认信息', '收款记录', '暂无账单', '暂无收款记录', 'state-panel--error']) {
assert.match(viewSource, new RegExp(expected))
}
for (const forbidden of ['确认账单', '发起争议', '新增收款', '核销', '修改金额']) {
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
for (const forbidden of ['发起争议', '新增收款', '核销', '修改金额']) {
assert.doesNotMatch(viewSource, new RegExp(forbidden))
}
})
test('catalogs customer finance as an assignable view-only role permission', () => {
test('keeps the existing customer finance view permission mapping', () => {
assert.match(permissionSource, /code: 'finance:bill:view'/)
assert.match(permissionSource, /不包含任何财务写操作/)
assert.match(permissionSource, /merchant:finance:statement:list/)
assert.match(permissionSource, /merchant:finance:statement:view/)
assert.match(permissionSource, /FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES/)
+56 -5
View File
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiError } from '@/api/http'
import { getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
import type { FinanceBillDetail, FinanceBillQuery, FinanceBillStatus, FinanceBillSummary } from '../contracts'
import { useAuthStore } from '@/modules/auth/public'
import { confirmMerchantFinanceStatement, getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
import type { FinanceBillDetail, FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
const loading = ref(false)
const auth = useAuthStore()
const detailLoading = ref(false)
const errorMessage = ref('')
const detailError = ref('')
@@ -13,6 +16,8 @@ const bills = ref<FinanceBillSummary[]>([])
const detail = ref<FinanceBillDetail | null>(null)
const selectedBillId = ref('')
const detailVisible = ref(false)
const confirming = ref(false)
const confirmationKeys = new Map<string, string>()
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
@@ -27,6 +32,7 @@ const statusOptions: Array<{ label: string; value: FinanceBillStatus }> = [
]
const hasData = computed(() => bills.value.length > 0)
const canConfirm = computed(() => auth.hasPermission('finance:bill:confirm'))
function formatAmount(value: string | null | undefined) {
if (!value) return '0.00'
@@ -39,6 +45,10 @@ function formatDate(value: string | null | undefined) {
return value.slice(0, 10)
}
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '-'
}
function statusLabel(status: FinanceBillStatus) {
return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' })[status] ?? status
}
@@ -47,6 +57,14 @@ function statusType(status: FinanceBillStatus) {
return ({ ISSUED: 'info', PARTIALLY_PAID: 'warning', PAID: 'success', VOIDED: 'danger', DRAFT: 'info' })[status] as 'info' | 'warning' | 'success' | 'danger'
}
function confirmationLabel(status: FinanceCustomerConfirmationStatus) {
return ({ NOT_REQUIRED: '无需确认', PENDING_CONFIRMATION: '待确认', CONFIRMED: '已确认' })[status]
}
function confirmationType(status: FinanceCustomerConfirmationStatus) {
return ({ NOT_REQUIRED: 'info', PENDING_CONFIRMATION: 'warning', CONFIRMED: 'success' })[status] as 'info' | 'warning' | 'success'
}
function messageOf(error: unknown) {
return error instanceof ApiError ? error.message : '账单加载失败,请稍后重试'
}
@@ -94,6 +112,37 @@ async function loadDetail(billId: string) {
}
}
async function confirmStatement() {
if (!detail.value || detail.value.customerConfirmationStatus !== 'PENDING_CONFIRMATION') return
try {
await ElMessageBox.confirm(
`确认对账单 ${detail.value.billNo} 的金额与费用明细无误?确认后平台将记录当前登录成员和确认时间。`,
'确认客户对账单',
{ type: 'warning', confirmButtonText: '确认无误', cancelButtonText: '取消' },
)
} catch {
return
}
confirming.value = true
const statementId = detail.value.id
let key = confirmationKeys.get(statementId)
if (!key) {
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
key = `statement-confirm-${statementId}-${randomPart}`
confirmationKeys.set(statementId, key)
}
try {
await confirmMerchantFinanceStatement(statementId, detail.value.version, key)
confirmationKeys.delete(statementId)
ElMessage.success('对账单已确认')
await Promise.all([loadDetail(statementId), loadBills(page.value)])
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '对账单确认失败,请重试')
} finally {
confirming.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.status = undefined
@@ -142,6 +191,7 @@ onMounted(() => { void loadBills(1) })
<el-table-column label="已收" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template></el-table-column>
<el-table-column label="未收余额" width="145" align="right"><template #default="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="客户确认" width="110"><template #default="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</el-tag></template></el-table-column>
<template #empty><div class="table-empty"><CircleDollarSign :size="32" /><strong>暂无账单</strong><span>平台财务出账后,这里会显示本企业账单。</span></div></template>
</el-table>
@@ -152,10 +202,11 @@ onMounted(() => { void loadBills(1) })
<div v-loading="detailLoading" class="detail-content">
<div v-if="detailError" class="state-panel state-panel--error" role="alert"><p>{{ detailError }}</p><el-button @click="loadDetail(selectedBillId)">重试</el-button></div>
<template v-else-if="detail">
<div class="detail-heading"><div><h2>{{ detail.billNo }}</h2><p>出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }} · 未收 <strong class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</strong></p></div><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag></div>
<div class="detail-heading"><div><h2>{{ detail.billNo }}</h2><p>出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }} · 未收 <strong class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</strong></p></div><div class="detail-statuses"><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag><el-tag :type="confirmationType(detail.customerConfirmationStatus)">{{ confirmationLabel(detail.customerConfirmationStatus) }}</el-tag></div></div>
<section class="confirmation-panel" aria-label="客户确认信息"><div><strong>{{ confirmationLabel(detail.customerConfirmationStatus) }}</strong><p v-if="detail.customerConfirmationStatus === 'CONFIRMED'">{{ detail.customerConfirmedByName || '当前企业成员' }} · {{ formatDateTime(detail.customerConfirmedAt) }}</p><p v-else>请核对本期金额和费用明细,确认后将记录当前登录成员与确认时间。</p></div><el-button v-if="detail.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" type="primary" :loading="confirming" @click="confirmStatement">确认账单</el-button></section>
<section class="detail-section"><h3>费用明细</h3><el-table :data="detail.lines" size="small"><el-table-column prop="feeName" label="费用项目" min-width="180" /><el-table-column label="金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column></el-table></section>
<section class="detail-section"><h3>收款记录</h3><el-table v-if="detail.receipts.length" :data="detail.receipts" size="small"><el-table-column prop="receivedAt" label="收款日期" width="110"><template #default="{ row }">{{ formatDate(row.receivedAt) }}</template></el-table-column><el-table-column prop="receiptNo" label="收款编号" min-width="130" /><el-table-column label="本次金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template></el-table-column><el-table-column prop="method" label="方式" min-width="100" /></el-table><div v-else class="sub-empty">暂无收款记录</div></section>
<p class="readonly-notice">账单金额和收款记录仅供查看。如有疑问,请线下联系平台财务。</p>
<p class="readonly-notice">如对账单内容有疑问,请先线下联系平台财务,核对无误后再确认。</p>
</template>
</div>
</el-drawer>
@@ -171,6 +222,6 @@ onMounted(() => { void loadBills(1) })
.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; }
.table-empty { display: flex; align-items: center; flex-direction: column; gap: 8px; padding: 42px 12px; color: var(--muted); }.table-empty strong { color: var(--navy); }.table-empty span { font-size: 12px; }
.pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; color: var(--muted); font-size: 12px; }
.detail-content { min-height: 300px; }.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.detail-heading h2 { margin: 0 0 5px; color: var(--navy); font-size: 18px; }.detail-heading p { margin: 0; color: var(--muted); font-size: 13px; }.detail-section { margin-top: 22px; }.detail-section h3 { margin: 0 0 10px; color: var(--navy); font-size: 14px; }.sub-empty { padding: 22px; color: var(--muted); font-size: 12px; text-align: center; background: #f7fafb; }.readonly-notice { margin: 22px 0 0; padding: 11px 12px; color: #45615d; background: #edf6f4; font-size: 12px; }
.detail-content { min-height: 300px; }.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.detail-heading h2 { margin: 0 0 5px; color: var(--navy); font-size: 18px; }.detail-heading p { margin: 0; color: var(--muted); font-size: 13px; }.detail-statuses { display: flex; align-items: center; gap: 8px; }.confirmation-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d7e9e4; border-radius: 8px; background: #f3f9f7; }.confirmation-panel strong { color: var(--navy); }.confirmation-panel p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }.detail-section { margin-top: 22px; }.detail-section h3 { margin: 0 0 10px; color: var(--navy); font-size: 14px; }.sub-empty { padding: 22px; color: var(--muted); font-size: 12px; text-align: center; background: #f7fafb; }.readonly-notice { margin: 22px 0 0; padding: 11px 12px; color: #45615d; background: #edf6f4; font-size: 12px; }
@media (max-width: 720px) { .keyword-input, .status-select { width: 100%; }.filter-bar > .el-button { flex: 1; }.content-card { padding: 12px; }.pagination-row { align-items: flex-start; flex-direction: column; }.finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
</style>
@@ -73,7 +73,7 @@ test('uses the full editor dialog width for cargo details and cost estimates', (
assert.match(source, /:class="\{ 'is-dialog-order-form': !isCreatePage \}"/)
assert.match(source, /\.order-form\.is-dialog-order-form \{ width: 100%; max-width: none; \}/)
assert.match(source, /\.is-dialog-order-form > \.cargo-section, \.is-dialog-order-form > \.cost-estimate-section \{ width: 100%; max-width: none; \}/)
assert.match(source, /\.cost-estimate-cards \{ display: grid; grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/)
assert.match(source, /\.price-estimate__result \{ display: grid; grid-template-columns:/)
assert.match(source, /\.cargo-table-wrap \{ width: 100%;/)
})
@@ -361,6 +361,11 @@ test('estimates by warehouse, destination, tax mode, weight and volume without b
assert.match(source, /您仍可正常保存订单草稿/)
assert.match(source, /v-for="mode in availablePricingTaxModes"/)
assert.match(source, /pricingTaxModeLabel\(mode\)/)
assert.match(source, /priceEstimate\.chargeableQuantity/)
assert.match(source, /priceEstimate\.unitPrice/)
assert.match(source, /priceEstimate\.estimatedAmount/)
assert.match(source, /priceEstimateDisclaimer/)
assert.doesNotMatch(source, /3,310|2,990|2,500|静态预估展示/)
assert.doesNotMatch(source, /当前价格表不提供不包税报价|历史草稿的不包税设置/)
const saveSource = source.slice(source.indexOf('async function saveDraft()'), source.indexOf('async function submitOrder()'))
assert.doesNotMatch(saveSource, /loadPriceEstimate|priceEstimateError|estimateMerchantFirstLegPrice/)
@@ -485,6 +490,8 @@ test('copies reactive order drafts into plain request data', () => {
test('uses the global inbound-code policy to require old-system codes during parallel running', () => {
assert.match(source, /getMerchantInboundCodePolicy\(\)/)
assert.match(source, /async function validateEditorForm\(\)[\s\S]*?const policy = await getMerchantInboundCodePolicy\(\)/)
assert.match(source, /if \(policy\.mode === 'AUTO' && !editingId\.value\) form\.inboundCode = ''/)
assert.match(source, /const manualInboundCodeRequired = computed/)
assert.match(source, /v-if="manualInboundCodeRequired" label="旧系统入仓码"/)
assert.match(source, /当前全系统处于旧系统并跑模式/)
@@ -736,6 +736,25 @@ function pricingTaxModeLabel(mode: 'TAX_INCLUDED' | 'TAX_EXCLUDED') {
return mode === 'TAX_INCLUDED' ? '包税' : '不包税'
}
function formatEstimateMoney(value: number, currency: string) {
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
} catch {
return `${currency} ${value.toFixed(2)}`
}
}
function formatEstimateQuantity(value: number, unit: string) {
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 4 })} ${unit}`
}
const priceEstimateDisclaimer = computed(() => priceEstimate.value?.disclaimer || '仅供参考,最终以实际结算为准')
function resetPriceEstimate() {
estimateRequestSequence += 1
priceEstimate.value = null
@@ -815,6 +834,10 @@ async function loadPriceEstimate() {
}
function schedulePriceEstimate() {
estimateRequestSequence += 1
priceEstimate.value = null
priceEstimateError.value = ''
priceEstimateLoading.value = false
if (estimateTimer) clearTimeout(estimateTimer)
estimateTimer = setTimeout(() => void loadPriceEstimate(), 300)
}
@@ -1389,6 +1412,17 @@ async function importCargoTemplate(event: Event) {
}
async function validateEditorForm() {
try {
const policy = await getMerchantInboundCodePolicy()
inboundCodePolicy.value = policy
if (policy.mode === 'AUTO' && !editingId.value) form.inboundCode = ''
await nextTick()
} catch (error) {
inboundCodePolicy.value = null
ElMessage.warning(`入仓码配置加载失败:${errorMessage(error)}`)
return false
}
let fieldsValid = false
try {
fieldsValid = await orderFormRef.value?.validate() ?? false
@@ -1400,10 +1434,6 @@ async function validateEditorForm() {
return false
}
if (!inboundCodePolicy.value) {
ElMessage.warning('入仓码配置尚未加载,请稍后重试')
return false
}
if (manualInboundCodeRequired.value && !form.inboundCode?.trim()) {
ElMessage.warning('当前为旧系统并跑模式,请填写旧系统入仓码')
return false
@@ -1730,28 +1760,28 @@ async function searchOrders(model: QueryModel) {
</section>
<section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
<div class="order-section-heading"><div><h3>费用预估</h3></div></div>
<div class="cost-estimate-cards">
<article class="cost-estimate-card">
<span class="cost-estimate-badge is-fastest">最快</span>
<strong class="cost-estimate-days">10天</strong>
<div class="cost-estimate-price"><small>¥</small><strong>3,310</strong></div>
<p>优先配送,时效保障</p>
</article>
<article class="cost-estimate-card is-recommended">
<span class="cost-estimate-badge is-recommend">推荐</span>
<strong class="cost-estimate-days">13天</strong>
<div class="cost-estimate-price"><small>¥</small><strong>2,990</strong></div>
<p>性价比之选</p>
</article>
<article class="cost-estimate-card">
<span class="cost-estimate-badge is-economy">经济</span>
<strong class="cost-estimate-days">16天</strong>
<div class="cost-estimate-price"><small>¥</small><strong>2,500</strong></div>
<p>经济实惠</p>
</article>
<div class="order-section-heading"><div><h3>参考运费</h3><p>根据当前价格表、渠道、目的仓以及货物重量和体积实时试算。</p></div></div>
<div class="price-estimate" :class="{ 'is-loading': priceEstimateLoading }" aria-live="polite">
<div v-if="priceEstimateLoading" class="price-estimate__state" v-loading="true">正在获取实时参考报价</div>
<div v-else-if="priceEstimateError" class="price-estimate__state is-error" role="alert">
<AlertTriangle :size="20" />
<div><strong>参考报价暂时不可用</strong><span>{{ priceEstimateError }},您仍可正常保存订单草稿。</span></div>
<el-button @click="loadPriceEstimate">重新试算</el-button>
</div>
<div v-else-if="priceEstimate" class="price-estimate__result">
<div class="price-estimate__channel">
<span>当前渠道</span>
<strong>{{ selectedPricingChannel?.channelName || priceEstimate.channelCode }}</strong>
<small>{{ pricingTaxModeLabel(priceEstimate.pricingTaxMode) }}<template v-if="selectedPricingChannel?.referenceTransit"> · 参考时效 {{ selectedPricingChannel.referenceTransit }}</template></small>
</div>
<div><span>计费量</span><strong>{{ formatEstimateQuantity(priceEstimate.chargeableQuantity, priceEstimate.chargeUnit) }}</strong></div>
<div><span>基础单价</span><strong>{{ formatEstimateMoney(priceEstimate.unitPrice, priceEstimate.currency) }} / {{ priceEstimate.chargeUnit }}</strong></div>
<div class="price-estimate__amount"><span>参考运费</span><strong>{{ formatEstimateMoney(priceEstimate.estimatedAmount, priceEstimate.currency) }}</strong></div>
<small>{{ priceEstimateDisclaimer }}</small>
</div>
<div v-else-if="form.destinationType === 'PRIVATE'" class="price-estimate__state">私人仓暂不提供在线参考报价,您仍可正常保存订单草稿。</div>
<div v-else class="price-estimate__state">请先选择国内收货仓、渠道、目的仓,并填写大于 0 的总毛重和总体积。</div>
</div>
<p class="cost-estimate-note">以上费用为静态预估展示,实际费用以最终结算结果为准。</p>
</section>
</el-form>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
@@ -2015,18 +2045,20 @@ async function searchOrders(model: QueryModel) {
.field-guidance--error { color: #b54708; }
.cost-estimate-section { width: 100%; }
.cost-estimate-section .order-section-heading { margin-bottom: 12px; }
.cost-estimate-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.cost-estimate-card { position: relative; display: flex; min-height: 158px; box-sizing: border-box; align-items: center; justify-content: center; flex-direction: column; padding: 24px 18px 18px; border: 1px solid #dde4f2; border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(15, 31, 58, .025); text-align: center; }
.cost-estimate-card.is-recommended { border: 2px solid #637fec; background: #f8f8ff; box-shadow: 0 8px 22px rgba(99, 127, 236, .13); }
.cost-estimate-badge { position: absolute; top: -9px; right: 14px; padding: 3px 11px; border-radius: 12px; color: #fff; font-size: 10px; font-weight: 650; line-height: 1.4; }
.cost-estimate-badge.is-fastest, .cost-estimate-badge.is-recommend { background: #5368c6; }
.cost-estimate-badge.is-economy { background: #71858e; }
.cost-estimate-days { color: #30343b; font-size: 21px; }
.cost-estimate-price { display: flex; align-items: baseline; gap: 5px; margin-top: 13px; color: #637fec; }
.cost-estimate-price small { font-size: 15px; font-weight: 700; }
.cost-estimate-price strong { font-size: 29px; line-height: 1; }
.cost-estimate-card p { margin: 12px 0 0; color: #98a0ad; font-size: 11px; }
.cost-estimate-note { margin: 10px 0 0; color: #98a0ad; font-size: 10px; text-align: right; }
.price-estimate { min-height: 126px; overflow: hidden; border: 1px solid #dde4f2; border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(15, 31, 58, .025); }
.price-estimate__state { display: flex; min-height: 126px; align-items: center; justify-content: center; gap: 10px; padding: 24px; color: #7b8798; font-size: 12px; text-align: center; }
.price-estimate__state.is-error { justify-content: flex-start; background: #fffaf0; color: #b54708; text-align: left; }
.price-estimate__state.is-error > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 4px; }
.price-estimate__state.is-error strong { color: #7a2e0e; font-size: 13px; }
.price-estimate__state.is-error span { overflow-wrap: anywhere; }
.price-estimate__result { display: grid; grid-template-columns: minmax(190px, 1.3fr) repeat(2, minmax(140px, .8fr)) minmax(190px, 1fr); align-items: stretch; }
.price-estimate__result > div { display: flex; min-width: 0; min-height: 96px; justify-content: center; flex-direction: column; gap: 7px; padding: 18px 20px; border-right: 1px solid #edf0f5; }
.price-estimate__result > div > span { color: #8b96a6; font-size: 11px; }
.price-estimate__result > div > strong { overflow-wrap: anywhere; color: #303b50; font-size: 15px; }
.price-estimate__channel small { color: #7b8798; font-size: 10px; line-height: 1.5; }
.price-estimate__result .price-estimate__amount { border-right: 0; background: #f8f8ff; }
.price-estimate__result .price-estimate__amount strong { color: #637fec; font-size: 25px; }
.price-estimate__result > small { grid-column: 1 / -1; padding: 9px 14px; border-top: 1px solid #edf0f5; color: #98a0ad; font-size: 10px; text-align: right; }
.selected-destination { display: flex; width: min(100%, 720px); align-items: flex-start; gap: 9px; margin: -4px 0 18px 140px; padding: 10px 12px; background: #f2f7f6; color: var(--primary); }
.selected-destination svg { flex: 0 0 17px; margin-top: 2px; }
.selected-destination div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
@@ -2224,8 +2256,11 @@ async function searchOrders(model: QueryModel) {
.compact-transport-grid > :deep(.el-form-item__label) { min-height: auto; justify-content: flex-start; padding: 0 0 8px; line-height: 1.4; }
.cargo-table-toolbar { align-items: stretch; flex-direction: column; }
.cargo-import-actions, .cargo-table-actions { flex-wrap: wrap; }
.cost-estimate-cards { grid-template-columns: 1fr; }
.cost-estimate-card { min-height: 140px; }
.price-estimate__result { grid-template-columns: 1fr; }
.price-estimate__result > div { min-height: auto; border-right: 0; border-bottom: 1px solid #edf0f5; }
.price-estimate__result > small { grid-column: auto; }
.price-estimate__result > small { text-align: left; }
.price-estimate__state.is-error { align-items: flex-start; flex-direction: column; }
.form-span-full { grid-column: auto; }
.editor-actions :deep(.el-button) { min-height: 44px; }
.desktop-order-table { display: none; }
@@ -51,6 +51,9 @@ test('creates one real backend draft per order row and keeps its products togeth
})
test('requires the old inbound code globally only while legacy manual mode is active', () => {
assert.match(source, /async function refreshInboundCodePolicyForWrite\(\)/)
assert.match(source, /if \(!await refreshInboundCodePolicyForWrite\(\)\) return/)
assert.match(source, /if \(policy\.mode === 'AUTO'\)[\s\S]*?row\.inboundCode = ''/)
assert.match(source, /manualInboundCodeRequired\.value[\s\S]*?rows\.findIndex\(\(row\) => !row\.inboundCode\.trim\(\)\)/)
assert.match(source, /请填写第 \$\{missingInboundCodeIndex \+ 1\} 行的旧系统入仓码/)
assert.match(source, /!row\.channelKey \|\| !row\.destinationWarehouseName/)
+16 -4
View File
@@ -318,11 +318,23 @@ async function loadChannels() {
}
}
async function submitQuickOrder() {
if (!inboundCodePolicy.value) {
ElMessage.warning('入仓码规则尚未加载,请刷新页面后重试')
return
async function refreshInboundCodePolicyForWrite() {
try {
const policy = await getMerchantInboundCodePolicy()
inboundCodePolicy.value = policy
if (policy.mode === 'AUTO') {
rows.forEach((row) => { row.inboundCode = '' })
}
return true
} catch (error) {
inboundCodePolicy.value = null
ElMessage.warning(error instanceof Error ? error.message : '入仓码规则加载失败,请稍后重试')
return false
}
}
async function submitQuickOrder() {
if (!await refreshInboundCodePolicyForWrite()) return
const missingInboundCodeIndex = manualInboundCodeRequired.value
? rows.findIndex((row) => !row.inboundCode.trim())
: -1