This commit is contained in:
2026-08-11 19:35:28 +08:00
parent 886c7d3529
commit c690a30422
48 changed files with 935 additions and 171 deletions
@@ -86,7 +86,10 @@ onMounted(load)
</template>
<style scoped>
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups :deep(.el-input-number) { width:130px; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups > div,.edit-groups > div { min-width:0; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.edit-groups table { table-layout:fixed; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child,.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups { overflow:auto; }.edit-groups > div { min-width:620px; }.panel-heading :deep(.el-button) { min-height:44px; } }
.edit-group-heading h3 { min-width:0; }
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
@media (max-width:900px) { .edit-groups { grid-template-columns:1fr; } }
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups > div { overflow-x:auto; }.edit-groups table { min-width:560px; }.panel-heading :deep(.el-button) { min-height:44px; } }
</style>
@@ -92,9 +92,14 @@ onMounted(() => { void load() })
.tier-item { display:flex; align-items:center; justify-content:space-between; gap:6px; min-height:24px; padding:2px 6px; border-left:2px solid #409eff; background:#f6f9fd; }
.tier-item span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
.tier-item strong { color:var(--cc-text); font-size:12px; font-weight:650; white-space:nowrap; }
.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
.groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px; }
.groups > div { min-width:0; }
.groups :deep(.el-table) { width:100%; }
.groups :deep(.el-input-number) { width:100%; min-width:0; }
.groups h3 { margin:0 0 10px; font-size:15px; }
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
.group-heading h3 { margin:0; }
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .groups,.tier-list { grid-template-columns:1fr; } }
.group-heading h3 { min-width:0; margin:0; }
.group-heading :deep(.el-button) { flex:0 0 auto; }
@media (max-width:900px) { .groups { grid-template-columns:1fr; } }
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .tier-list { grid-template-columns:1fr; } .groups > div { overflow-x:auto; } .groups :deep(.el-table) { min-width:560px; } }
</style>
+28 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, getFinanceFeeSource, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
@@ -63,6 +63,33 @@ describe('finance api', () => {
expect(payables[0]).toMatchObject({ id: '71', settlementNo: 'AP-71', feeItemName: '拖车费', payableAmount: 680.5, currency: 'CNY' })
})
it('derives the numeric fee source id without treating manual costs as reviewable sources', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [
{ poolItemId: 'RECEIVABLE:42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, feeItemName: '运费', amount: 100, status: 'AVAILABLE' },
{ poolItemId: 'PAYABLE:43', direction: 'PAYABLE', sourceType: 'MANUAL_COST', sourceId: 43, sourceVersion: 1, feeItemName: '拖车费', amount: 200, status: 'POSTED' },
] })
const result = await listFinanceFeePool('10001')
expect(result.map((item) => item.feeSourceId)).toEqual(['42', null])
})
it('uses the formal G2 detail, review and settlement-inclusion contracts', async () => {
const detail = { feeSourceId: 42, sourceType: 'INBOUND_FEE', sourceFeeId: 81, sourceVersion: 3, direction: 'RECEIVABLE', customerId: 10001, customerName: '客户A', inboundId: 91, inboundCode: 'IB-91', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 4, operationLogs: [{ occurredAt: '2026-08-11T12:00:00', action: 'FEE_SOURCE_REVIEW_APPROVE', operatorName: '财务A', result: 'SUCCESS' }] }
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g2', traceId: 'trace-g2', data: detail })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'success', data: detail })
const result = await getFinanceFeeSource('42')
await reviewFinanceFeeSource('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 })
await updateFinanceFeeSourceSettlementInclusion('42', { included: true, reason: '本期结算', expectedVersion: 4 })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42')
expect(post).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/review', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(put).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/settlement-inclusion', { included: true, reason: '本期结算', expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4, currency: 'CNY' })
})
it('keeps receipt and cost commands on CNY with real voucher ids', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} })
+37 -7
View File
@@ -5,9 +5,9 @@ const basePath = '/platform/finance/v1'
export const financePhase1Capabilities = {
accountList: true,
accountLedger: true,
feeSourceDetail: false,
feeSourceReview: false,
feeSourceSettlementInclusion: false,
feeSourceDetail: true,
feeSourceReview: true,
feeSourceSettlementInclusion: true,
reconciliationCreate: false,
reconciliationActions: false,
receivableSettlementCreate: false,
@@ -70,7 +70,11 @@ export async function getFinanceCustomerAccount(customerId: string): Promise<Fin
export async function listFinanceFeePool(customerId: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise<FinanceFeePoolItem[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params: { customerId, direction } })
return unwrapApiResponse(response).map((item) => ({ poolItemId: stringValue(item.poolItemId), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }))
return unwrapApiResponse(response).map((item) => {
const poolItemId = stringValue(item.poolItemId)
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
return { poolItemId, feeSourceId, direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
})
}
export async function listFinanceAccounts(params: { page?: number; size?: number; keyword?: string; status?: string } = {}): Promise<{ records: FinanceAccountSummary[]; total: number; pages: number }> {
@@ -94,11 +98,37 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
}
export async function getFinanceFeeSource(feeSourceId: string): Promise<FinanceFeeSourceDetail> {
throw new FinanceCapabilityBlockedError('getFinanceFeeSource')
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}`)
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }) { throw new FinanceCapabilityBlockedError('reviewFinanceFeeSource') }
export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: string, payload: { included: boolean; reason: string; expectedVersion: number }) { throw new FinanceCapabilityBlockedError('updateFeeSourceSettlementInclusion') }
function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
const nullableString = (value: unknown) => value == null ? null : stringValue(value)
const nullableNumber = (value: unknown) => value == null ? null : numberValue(value)
return {
poolItemId: stringValue(item.feeSourceId), feeSourceId: stringValue(item.feeSourceId),
sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceFeeId), sourceFeeId: stringValue(item.sourceFeeId),
sourceVersion: numberValue(item.sourceVersion), direction: stringValue(item.direction) as FinanceFeeSourceDetail['direction'],
customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName),
orderId: nullableString(item.orderId), inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode),
containerId: nullableString(item.containerId), containerNo: stringValue(item.containerNo), businessDate: stringValue(item.businessDate),
feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice), amount: numberValue(item.amount), currency: 'CNY',
reviewStatus: stringValue(item.reviewStatus) as FinanceFeeSourceDetail['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded),
sourceStatus: stringValue(item.sourceStatus), status: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
reviewReason: stringValue(item.reviewReason) || undefined, settlementReason: stringValue(item.settlementReason) || undefined,
sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion), createdAt: stringValue(item.businessDate),
operationLogs: (Array.isArray(item.operationLogs) ? item.operationLogs : []).map((entry) => { const row = entry as RawResource; return { occurredAt: stringValue(row.occurredAt), action: stringValue(row.action), operatorName: stringValue(row.operatorName), result: stringValue(row.result), remark: stringValue(row.remark) || undefined } }),
}
}
export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/review`, payload, mutationHeaders('fee-source-review'))
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: string, payload: { included: boolean; reason: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
const response = await http.put<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`, payload, mutationHeaders('fee-source-settlement'))
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function createReconciliationStatement(payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createReconciliationStatement') }
export async function actionReconciliationStatement(statementId: string, payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('actionReconciliationStatement') }
export async function createReceivableSettlement(payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createReceivableSettlement') }
@@ -57,6 +57,25 @@ describe('finance five-page delivery contract', () => {
expect(page.indexOf("key: 'actions'")).toBeLessThan(page.indexOf("key: 'customerName'"))
for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state)
})
it('connects G2 fee sources to real detail, review and settlement operations', () => {
const page = source('src/modules/finance/pages/FinanceCustomerArapView.vue')
for (const marker of [
'`${basePath}/fee-sources/${feeSourceId}`',
'`${basePath}/fee-sources/${feeSourceId}/review`',
'`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`',
]) expect(api).toContain(marker)
for (const operation of ['getFinanceFeeSource', 'reviewFinanceFeeSource', 'updateFinanceFeeSourceSettlementInclusion']) expect(page).toContain(operation)
for (const permission of ['platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) expect(page).toContain(permission)
expect(page).toContain('expectedVersion: detail.value.operationVersion')
const columns = page.slice(page.indexOf('const columns:'))
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'direction'"))
expect(page).toContain('<ApiErrorMeta :details="detailError"')
expect(page).toContain('<ApiErrorMeta :details="reviewError"')
expect(page).toContain('<ApiErrorMeta :details="inclusionError"')
expect(page).not.toContain('接口尚未提供')
expect(page).not.toContain('disabled title=')
})
})
function basePathMarker(endpoint: string) {
@@ -13,6 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel, financeDocumentTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
const access = useAccessPreviewStore()
@@ -118,7 +119,7 @@ onMounted(() => void load())
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
<FormSection v-if="!editing" title="账户流水" description="按后端正式流水 DTO 展示应收、应付及变动后未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ entry.businessType }} · {{ entry.documentType }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
<FormSection v-if="!editing" title="账户流水" description="按后端正式流水 DTO 展示应收、应付及变动后未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ financeBusinessTypeLabel(entry.businessType) }} · {{ financeDocumentTypeLabel(entry.documentType) }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
</template>
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
@@ -0,0 +1,125 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import type { FinanceFeeSourceDetail } from '../types'
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/finance', () => ({
listFinanceFeePool: vi.fn(),
getFinanceFeeSource: vi.fn(),
reviewFinanceFeeSource: vi.fn(),
updateFinanceFeeSourceSettlementInclusion: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value),
}
}
const detail: FinanceFeeSourceDetail = {
poolItemId: '42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceFeeId: '81', sourceVersion: 3,
customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '',
businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [],
}
async function mountView(permissions: string[]) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
return wrapper
}
async function loadRows(wrapper: Awaited<ReturnType<typeof mountView>>) {
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
await vm.load({ customerId: '10001', direction: '', status: '', keyword: '' })
await flushPromises()
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('FinanceCustomerArapView G2 operations', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
document.body.innerHTML = ''
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }])
vi.mocked(getFinanceFeeSource).mockReset().mockResolvedValue(detail)
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 })
})
it('does not expose or call G2 operations without their permissions', async () => {
const wrapper = await mountView(['platform:finance:bill:view'])
await loadRows(wrapper)
const buttonLabels = wrapper.findAll('button').map((button) => button.text())
expect(buttonLabels).not.toContain('详情')
expect(buttonLabels).not.toContain('审核')
expect(buttonLabels).not.toContain('结算')
expect(getFinanceFeeSource).not.toHaveBeenCalled()
expect(reviewFinanceFeeSource).not.toHaveBeenCalled()
expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled()
})
it('loads detail and submits review and settlement changes with the returned version', async () => {
const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement'])
await loadRows(wrapper)
const vm = wrapper.vm as unknown as {
openDetail(row: Record<string, unknown>): Promise<void>
openReview(row: Record<string, unknown>): Promise<void>
submitReview(): Promise<void>
openInclusion(row: Record<string, unknown>): Promise<void>
submitInclusion(): Promise<void>
reviewForm: { action: 'APPROVE' | 'REJECT'; reason: string }
inclusionForm: { included: boolean; reason: string }
}
const row = { id: 'RECEIVABLE:42', feeSourceId: '42' }
await vm.openDetail(row); await flushPromises()
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
expect(document.body.textContent).toContain('IB-91')
expect(document.body.textContent).toContain('100.00 CNY')
await vm.openReview(row); vm.reviewForm.action = 'APPROVE'; vm.reviewForm.reason = '核对无误'; await vm.submitReview()
expect(reviewFinanceFeeSource).toHaveBeenCalledWith('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 })
await vm.openInclusion(row); vm.inclusionForm.included = true; vm.inclusionForm.reason = '本期结算'; await vm.submitInclusion()
expect(updateFinanceFeeSourceSettlementInclusion).toHaveBeenCalledWith('42', { included: true, reason: '本期结算', expectedVersion: 3 })
})
it('renders request and trace ids when detail loading fails', async () => {
vi.mocked(getFinanceFeeSource).mockRejectedValueOnce(new ApiResponseError('费用来源不可用', 'FIN-FEE-404', 404, undefined, 'req-g2-error', 'trace-g2-error'))
const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view'])
await loadRows(wrapper)
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): Promise<void> }
await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' }); await flushPromises()
expect(document.body.textContent).toContain('费用来源不可用')
expect(document.body.textContent).toContain('req-g2-error')
expect(document.body.textContent).toContain('trace-g2-error')
})
})
@@ -1,20 +1,25 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, FileCheck2, Link2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listFinanceFeePool } from '../api/finance'
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { financeArapStatusOptions, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
interface ArapRow {
id: string
feeSourceId: string | null
customerName: string
direction: 'RECEIVABLE' | 'PAYABLE'
documentNo: string
@@ -39,6 +44,17 @@ const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const drawerOpen = ref(false)
const selected = ref<ArapRow | null>(null)
const detail = ref<FinanceFeeSourceDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const reviewOpen = ref(false)
const reviewSaving = ref(false)
const reviewError = ref<ApiErrorDetails | null>(null)
const reviewForm = reactive<{ action: 'APPROVE' | 'REJECT'; reason: string }>({ action: 'APPROVE', reason: '' })
const inclusionOpen = ref(false)
const inclusionSaving = ref(false)
const inclusionError = ref<ApiErrorDetails | null>(null)
const inclusionForm = reactive({ included: true, reason: '' })
const page = ref(1)
const pageSize = ref(20)
@@ -47,14 +63,15 @@ const canViewSource = computed(() => access.can('platform:finance:fee-source:vie
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
const stateError = computed(() => customersError.value || loadError.value)
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id), disabled: item.status !== 'ACTIVE' })) },
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }, { label: '已登记', value: 'RECORDED' }] },
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
{ key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', width: 130 },
@@ -79,12 +96,55 @@ async function load(model: QueryModel = filters) {
try {
const direction = String(filters.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const items = await listFinanceFeePool(customerId, direction || undefined)
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
}
function openDetail(row: Record<string, unknown>) { selected.value = row as unknown as ArapRow; drawerOpen.value = true }
function statusLabel(status: string) { return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废', RECORDED: '已登记' } as Record<string, string>)[status] || status }
async function loadDetail(row: ArapRow) {
detailLoading.value = true; detailError.value = null; detail.value = null
if (!row.feeSourceId) { detailError.value = { message: '该手工成本没有可审核的费用来源详情' }; detailLoading.value = false; return null }
try { detail.value = await getFinanceFeeSource(row.feeSourceId); return detail.value }
catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试'); return null }
finally { detailLoading.value = false }
}
async function openDetail(row: Record<string, unknown>) {
selected.value = row as unknown as ArapRow; drawerOpen.value = true
await loadDetail(selected.value)
}
async function openReview(row: Record<string, unknown>) {
const target = row as unknown as ArapRow; selected.value = target
const loaded = await loadDetail(target)
if (!loaded) { drawerOpen.value = true; return }
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
}
async function submitReview() {
if (!detail.value) return
if (reviewForm.action === 'REJECT' && !reviewForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
reviewSaving.value = true; reviewError.value = null
try {
detail.value = await reviewFinanceFeeSource(detail.value.feeSourceId, { action: reviewForm.action, reason: reviewForm.reason.trim() || undefined, expectedVersion: detail.value.operationVersion })
reviewOpen.value = false; ElMessage.success(reviewForm.action === 'APPROVE' ? '费用来源已审核通过' : '费用来源已驳回'); await load()
} catch (error) { reviewError.value = getApiErrorDetails(error, '费用来源审核失败,请重试') }
finally { reviewSaving.value = false }
}
async function openInclusion(row: Record<string, unknown>) {
const target = row as unknown as ArapRow; selected.value = target
const loaded = await loadDetail(target)
if (!loaded) { drawerOpen.value = true; return }
Object.assign(inclusionForm, { included: !loaded.settlementIncluded, reason: '' }); inclusionError.value = null; inclusionOpen.value = true
}
async function submitInclusion() {
if (!detail.value) return
if (!inclusionForm.reason.trim()) { ElMessage.warning('请填写调整原因'); return }
inclusionSaving.value = true; inclusionError.value = null
try {
detail.value = await updateFinanceFeeSourceSettlementInclusion(detail.value.feeSourceId, { included: inclusionForm.included, reason: inclusionForm.reason.trim(), expectedVersion: detail.value.operationVersion })
inclusionOpen.value = false; ElMessage.success(inclusionForm.included ? '已纳入结算' : '已排除结算'); await load()
} catch (error) { inclusionError.value = getApiErrorDetails(error, '参与结算状态更新失败,请重试') }
finally { inclusionSaving.value = false }
}
function statusLabel(status: string) { return financeStatusLabel(status) }
function money(value: unknown) { return Number(value || 0).toFixed(2) }
onMounted(() => void loadCustomers())
</script>
@@ -96,13 +156,28 @@ onMounted(() => void loadCustomers())
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="load" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="请选择客户查询应收应付">
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :disabled="!canViewSource" :title="canViewSource ? '费用来源详情接口尚未提供' : '无费用来源查看权限'" @click="openDetail(row)"><Eye :size="15" />摘要</el-button><el-button v-if="canReviewSource" link type="primary" disabled title="审核接口尚未提供,暂不可操作"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" disabled title="参与结算接口尚未提供,暂不可操作"><Link2 :size="15" />结算</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</template>
</DataTable></section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" title="费用来源摘要" size="min(680px, 94vw)"><el-descriptions v-if="selected" :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ selected.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selected.status) }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="费用单据">{{ selected.documentNo }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ selected.sourceType }}</el-descriptions-item><el-descriptions-item label="来源追溯">{{ selected.sourceId }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ selected.feeName }}</el-descriptions-item><el-descriptions-item label="金额">{{ selected.amount }} CNY</el-descriptions-item><el-descriptions-item label="发生日期">{{ selected.occurredAt }}</el-descriptions-item></el-descriptions><el-alert type="info" :closable="false" title="费用来源详情、审核和参与结算接口尚未提供,当前仅展示列表摘要。" /><template #footer><el-button @click="drawerOpen = false">关闭</el-button></template></AppDrawer>
<AppDrawer v-model="drawerOpen" title="费用来源详情" size="min(760px, 94vw)" :loading="detailLoading">
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ detail.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
</template>
<template #footer><el-button @click="drawerOpen = false">关闭</el-button></template>
</AppDrawer>
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-alert v-if="reviewError" type="error" :closable="false"><ApiErrorMeta :details="reviewError" /></el-alert>
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="inclusionOpen" title="参与结算设置" confirm-text="保存设置" :confirm-loading="inclusionSaving" @confirm="submitInclusion">
<el-alert v-if="inclusionError" type="error" :closable="false"><ApiErrorMeta :details="inclusionError" /></el-alert>
<el-form label-position="top"><el-form-item label="结算状态"><el-switch v-model="inclusionForm.included" active-text="参与结算" inactive-text="排除结算" /></el-form-item><el-form-item label="调整原因"><el-input v-model="inclusionForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请说明参与或排除结算的原因" show-word-limit /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
@@ -110,7 +185,10 @@ onMounted(() => void loadCustomers())
.finance-arap-page { min-width: 0; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions .el-button { margin-left: 0; }
.muted-action { color: var(--cc-muted); }
.arap-detail { margin-bottom: 16px; }
.operation-log h3 { margin: 0 0 14px; font-size: 15px; }
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
</style>
@@ -12,15 +12,16 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getFinanceBill, listFinanceStatements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceBillDetail, FinanceStatementReadModel } from '../types'
const access = useAccessPreviewStore(); const customers = ref<PlatformTenantResource[]>([]); const filters = reactive<QueryModel>({ customerId: '' }); const rows = ref<FinanceStatementReadModel[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const customerError = ref<ApiErrorDetails | null>(null); const drawerOpen = ref(false); const detail = ref<FinanceBillDetail | null>(null); const detailError = ref<ApiErrorDetails | null>(null)
const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }]
const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, '客户对账单加载失败,请重试') } finally { loading.value = false } }
async function openDetail(row: FinanceStatementReadModel) { drawerOpen.value = true; detail.value = null; detailError.value = null; try { detail.value = await getFinanceBill(row.id) } catch (e) { detailError.value = getApiErrorDetails(e, '对账单详情加载失败,请重试') } }
onMounted(() => void loadCustomers())
</script>
<template>
<AdminPage as="div" class="finance-statements-page" spacing="normal"><el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" /><ListPageLayout v-else aria-label="客户对账单"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template><template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template><section v-if="!customerError && !error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单"><template #toolbar-actions><el-button type="primary" disabled title="接口尚未提供,暂不可操作"><Plus :size="16" />生成对账单</el-button></template><template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template><template #pagination-left>币种:CNY · 生成、出账、调整、作废待后端 G1-G4 契约冻结</template></DataTable></section></ListPageLayout><AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(760px, 94vw)"><LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ detail.status }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions><el-alert class="blocked-alert" type="info" :closable="false" title="对账单生成与状态操作接口尚未提供,当前仅支持真实详情查询。" /></template></AppDrawer></AdminPage>
<AdminPage as="div" class="finance-statements-page" spacing="normal"><el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" /><ListPageLayout v-else aria-label="客户对账单"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template><template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template><section v-if="!customerError && !error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单"><template #toolbar-actions><el-button type="primary" disabled title="接口尚未提供,暂不可操作"><Plus :size="16" />生成对账单</el-button></template><template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template><template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template><template #pagination-left>币种:CNY · 生成、出账、调整、作废待后端 G1-G4 契约冻结</template></DataTable></section></ListPageLayout><AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(760px, 94vw)"><LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions><el-alert class="blocked-alert" type="info" :closable="false" title="对账单生成与状态操作接口尚未提供,当前仅支持真实详情查询。" /></template></AppDrawer></AdminPage>
</template>
<style scoped>.finance-statements-page{min-width:0}.blocked-alert{margin-top:16px}</style>
@@ -12,6 +12,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { financePhase1Capabilities, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types'
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
@@ -28,14 +29,16 @@ const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:fi
const canView = computed(() => access.can(viewPermission.value))
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
const blocked = computed(() => props.kind === 'receivable' ? !financePhase1Capabilities.receivableSettlementCreate : !financePhase1Capabilities.payableSettlementCreate)
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'customerId', label: '客户', minWidth: 140 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }]
: [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'customerId', label: '客户', minWidth: 140 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }])
? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
: [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }])
async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.customerId)) : await listFinancePayableSettlements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, `${title.value}加载失败,请重试`) } finally { loading.value = false } }
function openDetail(row: FinanceReceivableSettlement | FinancePayableSettlement) { selected.value = row; drawerOpen.value = true }
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' }
onMounted(() => void loadCustomers())
</script>
<template>
@@ -48,12 +51,14 @@ onMounted(() => void loadCustomers())
<DataTable :data="rows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="请选择客户查询结算单">
<template #toolbar-actions><el-button type="primary" disabled :title="blocked ? '接口尚未提供,暂不可操作' : ''"><Plus :size="16" />生成{{ title }}</el-button></template>
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceReceivableSettlement | FinancePayableSettlement)"><Eye :size="15" />详情</el-button></template>
<template #customer="{ row }">{{ customerName(String(row.customerId)) }}</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #pagination-left>币种:CNY · 生成接口尚未提供时仅展示真实查询结果</template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="`${title}详情`" size="min(680px, 94vw)">
<el-descriptions v-if="selected" :column="2" border><el-descriptions-item label="结算单号">{{ selected.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerId }}</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '应收金额' : '应付金额'">{{ totalAmount(selected) }} CNY</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '未收金额' : '未付金额'">{{ selected.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ selected.status }}</el-descriptions-item></el-descriptions>
<el-descriptions v-if="selected" :column="2" border><el-descriptions-item label="结算单号">{{ selected.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(selected.customerId) }}</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '应收金额' : '应付金额'">{{ totalAmount(selected) }} CNY</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '未收金额' : '未付金额'">{{ selected.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selected.status) }}</el-descriptions-item></el-descriptions>
<el-alert class="blocked-alert" type="info" :closable="false" title="结算单详情/收付款接口尚未提供,当前不显示不可执行的提交按钮。" />
</AppDrawer>
</AdminPage>
@@ -46,6 +46,14 @@ describe('FinanceManualPage delivery contract', () => {
expect(source).not.toContain('mockRevision')
})
it('renders list and detail statuses through the finance translation contract', () => {
expect(source).toContain('financeBillStatusOptions')
expect(source).toContain('financeStatusLabel(selectedBill.status)')
expect(source).toContain('financeStatusLabel(revision.status)')
expect(source).toContain('financeStatusLabel(selectedLedgerRow.status)')
expect(source).not.toContain('{{ selectedBill.status }}')
})
it('uses business selectors instead of asking operators for internal ids', () => {
expect(source).toContain("type: 'select', label: '客户'")
expect(source).toContain('placeholder="请选择客户"')
@@ -22,6 +22,7 @@ import {
type UnifiedOrderResource,
} from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { financeBillStatusOptions, financeStatusLabel, financeStatusTone } from '../status'
type Mode = 'bills' | 'receipts' | 'costs'
type DrawerMode = 'create' | 'detail' | 'edit' | 'adjust'
@@ -103,7 +104,7 @@ const rows = computed(() => serverPaged.value ? allRows.value : allRows.value.sl
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '客户/单号/费用项目', props: { 'aria-label': '关键词' } },
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customerOptionsLoading.value }, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })) },
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }] }] : []),
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: financeBillStatusOptions }] : []),
])
const columns = computed<DataTableColumn[]>(() => {
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
@@ -486,7 +487,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
</div>
<el-button v-else link type="primary" @click="openLedgerDetail(row)"><Eye :size="15" />查看</el-button>
</template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' || row.status === 'CONFIRMED' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : row.status === 'CONFIRMED' ? '已确认' : row.status === 'VOIDED' ? '作废' : row.status }}</el-tag></template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #costType="{ row }">{{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</template>
<template #empty><el-empty description="暂无符合条件的财务记录" /></template>
<template #pagination-left>币种:CNY · 数据由平台财务人工维护</template>
@@ -498,7 +499,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
<template v-if="props.mode === 'bills' && drawerMode === 'detail' && selectedBill">
<el-descriptions :column="2" border class="bill-summary">
<el-descriptions-item label="账单编号">{{ selectedBill.billNo }}</el-descriptions-item><el-descriptions-item label="当前版本">v{{ selectedBill.version }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedBill.status }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedBill.status) }}</el-descriptions-item>
<el-descriptions-item label="账单日期">{{ selectedBill.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedBill.dueDate }}</el-descriptions-item>
<el-descriptions-item label="账单金额">{{ selectedBill.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedBill.outstandingAmount }} CNY</el-descriptions-item>
</el-descriptions>
@@ -513,7 +514,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
<div v-else class="revision-list">
<section v-for="revision in billRevisions" :key="revision.id" class="revision-item">
<div class="revision-heading"><strong>调整前版本 v{{ revision.version }}</strong><span>{{ revision.createdAt || '时间未知' }}</span></div>
<div class="revision-meta"><span>原因:{{ revision.reason || '未填写' }}</span><span>操作人ID:{{ revision.createdBy || '—' }}</span><span>状态:{{ revision.status || '—' }}</span><span>金额:{{ revision.totalAmount }} CNY</span></div>
<div class="revision-meta"><span>原因:{{ revision.reason || '未填写' }}</span><span>操作人ID:{{ revision.createdBy || '—' }}</span><span>状态:{{ financeStatusLabel(revision.status) }}</span><span>金额:{{ revision.totalAmount }} CNY</span></div>
<div v-for="(line, index) in revision.lines" :key="`${revision.id}-${line.sourceFeeId}-${index}`" class="revision-line"><span>{{ line.feeItemName || `费用来源 ${line.sourceFeeId}` }}</span><b>{{ line.amount }} CNY</b></div>
<p v-if="!revision.lines.length" class="revision-empty">该版本没有费用明细快照</p>
</section>
@@ -523,8 +524,8 @@ onMounted(() => { void load(); void loadCustomerOptions() })
<template v-else-if="props.mode !== 'bills' && drawerMode === 'detail' && selectedLedgerRow">
<el-descriptions :column="2" border class="bill-summary">
<el-descriptions-item label="结算单号">{{ selectedLedgerRow.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerNameById(selectedLedgerRow.customerId) }}</el-descriptions-item>
<template v-if="props.mode === 'receipts'"><el-descriptions-item label="账单日期">{{ selectedLedgerRow.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedLedgerRow.dueDate }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ selectedLedgerRow.status }}</el-descriptions-item></template>
<template v-else><el-descriptions-item label="费用项目">{{ selectedLedgerRow.feeName }}</el-descriptions-item><el-descriptions-item label="成本类型">{{ selectedLedgerRow.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</el-descriptions-item><el-descriptions-item label="关联来源">{{ selectedLedgerRow.referenceNo }}</el-descriptions-item><el-descriptions-item label="应付金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已付金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未付金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="登记时间">{{ selectedLedgerRow.createdAt }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedLedgerRow.status }}</el-descriptions-item></template>
<template v-if="props.mode === 'receipts'"><el-descriptions-item label="账单日期">{{ selectedLedgerRow.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedLedgerRow.dueDate }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedLedgerRow.status) }}</el-descriptions-item></template>
<template v-else><el-descriptions-item label="费用项目">{{ selectedLedgerRow.feeName }}</el-descriptions-item><el-descriptions-item label="成本类型">{{ selectedLedgerRow.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</el-descriptions-item><el-descriptions-item label="关联来源">{{ selectedLedgerRow.referenceNo }}</el-descriptions-item><el-descriptions-item label="应付金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已付金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未付金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="登记时间">{{ selectedLedgerRow.createdAt }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedLedgerRow.status) }}</el-descriptions-item></template>
</el-descriptions>
</template>
<el-form v-else-if="drawerMode !== 'detail'" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || drawerLoading">
@@ -572,7 +573,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。">
<label class="file-picker" :class="{ 'is-disabled': saving }"><FileUp :size="16" />{{ uploadedVoucher ? `已上传:${uploadedVoucher.fileName}` : file?.name || '选择凭证图片' }}<input type="file" accept="image/*" :disabled="saving" @change="selectFile" /></label>
<p v-if="voucherError" class="voucher-error" role="alert">{{ voucherError }}</p>
<p v-else-if="uploadedVoucher" class="voucher-success">凭证 fileId:{{ uploadedVoucher.fileId }},创建失败时可直接重试。</p>
<p v-else-if="uploadedVoucher" class="voucher-success">凭证已上传,创建失败时可直接重试。</p>
</FormSection>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : saving ? '上传并保存中' : '上传并保存' }}</el-button></div></template>
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const settlement = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
const arap = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
describe('finance presentation governance', () => {
it('shows customer names instead of internal customer IDs in formal settlements', () => {
expect(settlement).toContain('customerName(selected.customerId)')
expect(settlement).toContain('activeCustomers.value.map')
expect(settlement).not.toContain('{{ selected.customerId }}')
})
it('translates source codes and provides reason placeholders', () => {
expect(arap).toContain('financeSourceTypeLabel(detail.sourceType)')
expect(arap).toContain('financeOperationActionLabel(log.action)')
expect(arap).toContain('financeOperationResultLabel(log.result)')
expect(arap).toContain('请填写驳回原因')
expect(arap).toContain('请说明参与或排除结算的原因')
})
it('does not expose the uploaded voucher file ID in the form', () => {
expect(manual).toContain('凭证已上传,创建失败时可直接重试')
expect(manual).not.toContain('凭证 fileId:')
})
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { financeBusinessTypeLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
describe('finance status presentation', () => {
it.each([
['DRAFT', '草稿'],
['PENDING_REVIEW', '待审核'],
['ISSUED', '已出账'],
['REJECTED', '已驳回'],
['PARTIALLY_PAID', '部分结清'],
['PAID', '已结清'],
['VOIDED', '已作废'],
['AVAILABLE', '待结算'],
['LOCKED', '结算锁定'],
['BILLED', '已入账'],
['RECORDED', '已登记'],
['CONFIRMED', '已确认'],
['ACTIVE', '正常'],
['DISABLED', '停用'],
])('maps %s to %s', (status, label) => {
expect(financeStatusLabel(status)).toBe(label)
})
it.each([undefined, null, '', 'NEW_BACKEND_STATUS', 1])('never exposes unknown status %s', (status) => {
expect(financeStatusLabel(status)).toBe('未知状态')
expect(financeStatusTone(status)).toBe('info')
})
it('translates source and ledger codes without exposing unknown values', () => {
expect(financeSourceTypeLabel('CONTAINER_COST')).toBe('整柜成本')
expect(financeBusinessTypeLabel('RECEIPT')).toBe('收款')
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('其他来源')
})
})
+86
View File
@@ -0,0 +1,86 @@
export type FinanceStatusTone = 'success' | 'warning' | 'info' | 'danger' | 'primary'
interface FinanceStatusDefinition {
label: string
tone: FinanceStatusTone
}
const financeStatuses: Record<string, FinanceStatusDefinition> = {
ACTIVE: { label: '正常', tone: 'success' },
DISABLED: { label: '停用', tone: 'info' },
INACTIVE: { label: '停用', tone: 'info' },
DRAFT: { label: '草稿', tone: 'info' },
PENDING: { label: '待审核', tone: 'warning' },
PENDING_REVIEW: { label: '待审核', tone: 'warning' },
APPROVED: { label: '已审核', tone: 'success' },
ISSUED: { label: '已出账', tone: 'primary' },
REJECTED: { label: '已驳回', tone: 'danger' },
UNPAID: { label: '未支付', tone: 'warning' },
PARTIALLY_PAID: { label: '部分结清', tone: 'warning' },
PAID: { label: '已结清', tone: 'success' },
VOIDED: { label: '已作废', tone: 'info' },
AVAILABLE: { label: '待结算', tone: 'warning' },
LOCKED: { label: '结算锁定', tone: 'warning' },
BILLED: { label: '已入账', tone: 'success' },
RECORDED: { label: '已登记', tone: 'primary' },
CONFIRMED: { label: '已确认', tone: 'success' },
}
export const financeBillStatusOptions = [
{ label: '草稿', value: 'DRAFT' },
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '已出账', value: 'ISSUED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '部分结清', value: 'PARTIALLY_PAID' },
{ label: '已结清', value: 'PAID' },
{ label: '已作废', value: 'VOIDED' },
]
export const financeArapStatusOptions = [
...financeBillStatusOptions,
{ label: '待结算', value: 'AVAILABLE' },
{ label: '结算锁定', value: 'LOCKED' },
{ label: '已入账', value: 'BILLED' },
{ label: '已登记', value: 'RECORDED' },
{ label: '已确认', value: 'CONFIRMED' },
]
function normalizeStatus(status: unknown) {
return typeof status === 'string' ? status.trim().toUpperCase() : ''
}
export function financeStatusLabel(status: unknown) {
return financeStatuses[normalizeStatus(status)]?.label || '未知状态'
}
export function financeStatusTone(status: unknown): FinanceStatusTone {
return financeStatuses[normalizeStatus(status)]?.tone || 'info'
}
const financeSourceTypeLabels: Record<string, string> = {
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
}
const financeBusinessTypeLabels: Record<string, string> = {
BILL: '对账单', RECEIPT: '收款', COST: '成本', ADJUSTMENT: '账务调整', SETTLEMENT: '结算',
}
const financeDocumentTypeLabels: Record<string, string> = {
BILL: '对账单', RECEIPT: '收款单', COST: '成本单', SETTLEMENT: '结算单', REVISION: '调整记录',
}
const financeOperationActionLabels: Record<string, string> = {
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', INCLUDE: '参与结算', EXCLUDE: '排除结算', UPDATE: '更新',
}
const financeOperationResultLabels: Record<string, string> = {
SUCCESS: '操作成功', FAILED: '操作失败', PENDING: '待处理', PROCESSING: '处理中',
}
function codeLabel(value: unknown, labels: Record<string, string>, unknownLabel: string) {
const code = normalizeStatus(value)
if (!code) return '—'
return labels[code] || unknownLabel
}
export const financeSourceTypeLabel = (value: unknown) => codeLabel(value, financeSourceTypeLabels, '其他来源')
export const financeBusinessTypeLabel = (value: unknown) => codeLabel(value, financeBusinessTypeLabels, '其他业务')
export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '其他单据')
export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '其他操作')
export const financeOperationResultLabel = (value: unknown) => codeLabel(value, financeOperationResultLabels, '结果未知')
+15 -2
View File
@@ -45,6 +45,7 @@ export interface FinanceCustomerAccountReadModel {
export interface FinanceFeePoolItem {
poolItemId: string
feeSourceId: string | null
direction: 'RECEIVABLE' | 'PAYABLE'
sourceType: string
sourceId: string
@@ -136,14 +137,26 @@ export interface FinanceLedgerEntry {
}
export interface FinanceFeeSourceDetail extends FinanceFeePoolItem {
reviewStatus: string
feeSourceId: string
customerId: string
customerName: string
supplierId: string | null
supplierName: string
sourceFeeId: string
inboundCode: string
containerNo: string
businessDate: string
quantity: number | null
unitPrice: number | null
reviewStatus: FinanceReviewStatus
settlementIncluded: boolean
sourceStatus: string
statementGenerated: boolean
reviewReason?: string
settlementReason?: string
sourceHash?: string
operationLogs?: Array<{ occurredAt: string; action: string; operatorName: string; result: string; remark?: string }>
version?: number
operationVersion: number
}
export interface FinanceSettlementDetail {
@@ -73,4 +73,10 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
expect(source).toContain('approvePlatformFirstLegOrder(tenantId, String(order.id))')
expect(source).not.toContain('loadedTenantId.value')
})
it('does not expose unknown backend status or history action codes', () => {
expect(source).toContain("?.label || '未知状态'")
expect(source).toContain('item.reason || historyActionLabel(item.action)')
expect(source).not.toContain('item.reason || item.action')
})
})
@@ -141,7 +141,15 @@ const statusOptions: Array<{ value: PlatformFirstLegOrderStatus; label: string }
]
function statusLabel(status: PlatformFirstLegOrderStatus) {
return statusOptions.find((item) => item.value === status)?.label || status
return statusOptions.find((item) => item.value === status)?.label || '未知状态'
}
function historyActionLabel(action: string | null | undefined) {
return ({
CREATE: '创建订单', CREATED: '创建订单', SUBMIT: '提交订单', SUBMITTED: '提交订单',
APPROVE: '审核通过', APPROVED: '审核通过', REJECT: '审核驳回', REJECTED: '审核驳回',
CANCEL: '取消订单', CANCELLED: '取消订单', STATUS_UPDATE: '状态更新',
} as Record<string, string>)[action || ''] || '状态已更新。'
}
function transportLabel(order: PlatformFirstLegOrderResource) {
@@ -535,7 +543,7 @@ onMounted(() => void loadTenants())
<div class="detail-section">
<h3>状态历史</h3>
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || item.action || '状态已更新。' }}</p></el-timeline-item></el-timeline>
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || historyActionLabel(item.action) }}</p></el-timeline-item></el-timeline>
</div>
</template>
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用"><el-button type="primary" @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button></el-empty>
@@ -14,6 +14,7 @@ import { InboundMeasurementPanel } from '../measurement'
import { WeightOcrPanel } from '../weight'
import { MeasurementReviewPanel } from '../review'
import { InboundFeePanel } from '../fee'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
const props = defineProps<{
customerId: string
@@ -35,20 +36,7 @@ let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const statusLabel = computed(() => {
const status = detail.value?.summary.processStatus || ''
return ({
WAIT_MEASURE: '待测量',
MEASURING: '测量中',
MEASURE_PENDING: '待审核',
MEASURE_REJECTED: '测量已驳回',
WAIT_PRICING: '待计费',
FEE_CONFIRMING: '费用确认中',
INBOUNDING: '入库处理中',
INBOUNDED: '已入库',
VOIDED: '已作废',
} as Record<string, string>)[status] || status || '未知状态'
})
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
async function loadDetail() {
const serial = ++requestSerial
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { InboundFileSummary, InboundInternalNote, InboundPalletLocation, InboundStatusHistory } from './types'
import { inboundHistoryActionLabel, inboundProcessStatusLabels, inboundStatusLabel } from '../status'
defineProps<{
palletLocations: InboundPalletLocation[]
@@ -43,7 +44,7 @@ function date(value: string | null) {
</section>
<section class="history-section">
<div class="section-heading"><h3>状态历史</h3><span>{{ statusHistory.length }} 条</span></div>
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ value(history.action) }}</strong><span>{{ value(history.fromStatus) }} → {{ value(history.toStatus) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ inboundHistoryActionLabel(history.action) }}</strong><span>{{ inboundStatusLabel(history.fromStatus, inboundProcessStatusLabels) }} → {{ inboundStatusLabel(history.toStatus, inboundProcessStatusLabels) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
<p v-else class="muted-empty">暂无状态历史</p>
</section>
<section v-if="publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
@@ -23,4 +23,14 @@ describe('InboundSummaryPanel', () => {
expect(source).not.toContain('接收审核')
expect(source).not.toContain('客户确认费用')
})
it('renders aggregate statuses through the inbound translation contract', () => {
expect(source).toContain('inboundOrderStatusLabels')
expect(source).toContain('inboundLoadingStatusLabels')
expect(source).toContain('inboundReviewStatusLabels')
expect(source).not.toContain('{{ value(summary.orderStatusSnapshot) }}')
expect(source).toContain('firstLegBusinessTypeLabel(summary.businessType)')
expect(source).toContain('firstLegTaxModeLabel(summary.taxMode)')
expect(source).toContain('firstLegDestinationTypeLabel(summary.destinationType)')
})
})
@@ -1,15 +1,12 @@
<script setup lang="ts">
import { Box, MapPin, Scale, Warehouse } from '@lucide/vue'
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import type { InboundOrderSummary } from './types'
import { inboundFeeStatusLabels, inboundLoadingStatusLabels, inboundNotificationStatusLabels, inboundOrderStatusLabels, inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
withDefaults(defineProps<{ summary: InboundOrderSummary; publicRemark?: string | null }>(), { publicRemark: null })
const processLabels: Record<string, string> = {
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回',
WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废',
}
function processLabel(status: string) { return processLabels[status] || status || '未知状态' }
function processLabel(status: string) { return inboundStatusLabel(status, inboundProcessStatusLabels, '未知状态') }
function processType(status: string) {
if (status === 'INBOUNDED') return 'success'
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
@@ -24,10 +21,7 @@ function date(value: string | null) {
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
}
function yesNo(value: boolean | null) { return value === null ? '—' : value ? '是' : '否' }
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
const feeLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
const notificationLabels: Record<string, string> = { NOT_CREATED: '未创建', PENDING: '待发送', SUCCESS: '发送成功', FAILED: '发送失败' }
function label(value: string | null, labels: Record<string, string>) { return value ? labels[value] || value : '—' }
function label(value: string | null, labels: Record<string, string>) { return inboundStatusLabel(value, labels) }
</script>
<template>
@@ -46,13 +40,13 @@ function label(value: string | null, labels: Record<string, string>) { return va
<h4 id="inbound-basic-heading">基本信息</h4>
<dl class="summary-grid">
<div><dt>头程订单号</dt><dd>{{ value(summary.orderNo) }}</dd></div>
<div><dt>订单状态</dt><dd>{{ value(summary.orderStatusSnapshot) }}</dd></div>
<div><dt>订单状态</dt><dd>{{ label(summary.orderStatusSnapshot, inboundOrderStatusLabels) }}</dd></div>
<div><dt>客户</dt><dd>{{ value(summary.customerName) }}</dd></div>
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
<div><dt>业务类型</dt><dd>{{ value(summary.businessType) }}</dd></div>
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(summary.businessType) }}</dd></div>
<div><dt>渠道</dt><dd>{{ value(summary.channelName || summary.channelCode) }}</dd></div>
<div><dt>买单 / 退税</dt><dd>{{ value(summary.taxMode) }}</dd></div>
<div><dt>是否整柜</dt><dd>{{ value(summary.containerMode) }}</dd></div>
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(summary.taxMode) }}</dd></div>
<div><dt>是否整柜</dt><dd>{{ firstLegContainerModeLabel(summary.containerMode) }}</dd></div>
<div><dt>商检验</dt><dd>{{ yesNo(summary.customsInspection) }}</dd></div>
<div><dt>备注</dt><dd>{{ value(publicRemark) }}</dd></div>
<div><dt>创建时间</dt><dd>{{ date(summary.createdAt) }}</dd></div>
@@ -65,11 +59,11 @@ function label(value: string | null, labels: Record<string, string>) { return va
<div><dt><Warehouse :size="14" />收货仓</dt><dd>{{ value(summary.warehouseName) }}</dd></div>
<div><dt>交货仓</dt><dd>{{ value(summary.deliveryWarehouseName) }}</dd></div>
<div><dt><MapPin :size="14" />目的仓</dt><dd>{{ value(summary.destinationName || summary.destinationCode) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ value(summary.destinationType) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ firstLegDestinationTypeLabel(summary.destinationType) }}</dd></div>
<div><dt>集装箱号</dt><dd>{{ value(summary.containerNo) }}</dd></div>
<div><dt>柜号</dt><dd>{{ value(summary.cabinetNo) }}</dd></div>
<div><dt>船名 / 航次</dt><dd>{{ value(summary.vesselName) }} / {{ value(summary.voyageNo) }}</dd></div>
<div><dt>配载状态</dt><dd>{{ value(summary.loadingStatus) }}</dd></div>
<div><dt>配载状态</dt><dd>{{ label(summary.loadingStatus, inboundLoadingStatusLabels) }}</dd></div>
<div><dt>预计到达</dt><dd>{{ date(summary.expectedArrivalAt) }}</dd></div>
<div><dt>开船 / 到港</dt><dd>{{ date(summary.sailingAt) }} / {{ date(summary.portArrivalAt) }}</dd></div>
<div><dt>快递公司</dt><dd>{{ value(summary.expressCompanyName || summary.expressCompanyCode) }}</dd></div>
@@ -87,11 +81,11 @@ function label(value: string | null, labels: Record<string, string>) { return va
<div><dt>总材积重</dt><dd>{{ number(summary.volumeWeightKg) }} kg</dd></div>
<div><dt>计费毛重</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
<div><dt>计费体积</dt><dd>{{ number(summary.chargeableVolumeCbm) }} CBM</dd></div>
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, measureReviewLabels) }}</dd></div>
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, feeLabels) }}</dd></div>
<div><dt>费用审核状态</dt><dd>{{ value(summary.feeReviewStatus) }}</dd></div>
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, inboundReviewStatusLabels) }}</dd></div>
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, inboundFeeStatusLabels) }}</dd></div>
<div><dt>费用审核状态</dt><dd>{{ label(summary.feeReviewStatus, inboundReviewStatusLabels) }}</dd></div>
<div><dt>测量完成时间</dt><dd>{{ date(summary.measureCompletedAt) }}</dd></div>
<div><dt>通知状态</dt><dd>{{ label(summary.notificationStatus, notificationLabels) }}</dd></div>
<div><dt>通知状态</dt><dd>{{ label(summary.notificationStatus, inboundNotificationStatusLabels) }}</dd></div>
</dl>
</section>
@@ -79,6 +79,7 @@ describe('InboundFeePanel', () => {
expect(document.body.textContent).toContain('附加费 ¥100.00')
expect(document.body.textContent).toContain('133.6 KG')
expect(document.body.textContent).toContain('按重量计费')
expect(document.body.textContent).toContain('FBA 海运')
expect(document.body.textContent).toContain('¥8.50 / KG')
expect(document.body.textContent).toContain('RULE-FBA-KG · V3')
expect(document.body.textContent).toContain('RATE-LAX · V5')
+5 -4
View File
@@ -2,6 +2,7 @@
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { CircleDollarSign, PencilLine, RefreshCw } from '@lucide/vue'
import { firstLegBusinessTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -74,7 +75,7 @@ function feeStatusInfo(status?: string) {
SENT: { label: '已发布', type: 'success' },
SUPERSEDED: { label: '历史版本', type: 'info' },
}
return map[status || ''] || { label: status || '未知状态', type: 'info' as const }
return map[status || ''] || { label: '未知状态', type: 'info' as const }
}
function formatNumber(value: number | null | undefined, digits = 3) {
@@ -104,7 +105,7 @@ function formatDateTime(value: string | null | undefined) {
}
function taxModeLabel(value: string) {
return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value
return firstLegTaxModeLabel(value)
}
function roundingModeLabel(value: string) {
@@ -112,7 +113,7 @@ function roundingModeLabel(value: string) {
UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下',
HALF_UP: '四舍五入', HALF_DOWN: '五舍六入', HALF_EVEN: '银行家舍入',
}
return labels[value] || value
return labels[value] || '未知规则'
}
async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
@@ -314,7 +315,7 @@ defineExpose({ reload: loadFee })
<div class="section-title"><div><h4 id="rule-title">客户计费规则</h4><p>{{ snapshot.ruleCode }} · V{{ snapshot.chargeRuleVersion }}</p></div></div>
<dl>
<div><dt>客户</dt><dd>{{ snapshot.customerName }}</dd></div>
<div><dt>业务类型</dt><dd>{{ snapshot.businessType }}</dd></div>
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(snapshot.businessType) }}</dd></div>
<div><dt>计费口径</dt><dd>{{ chargeModeLabel }}</dd></div>
<div><dt>舍入规则</dt><dd>保留 {{ snapshot.roundingScale }} 位,{{ roundingModeLabel(snapshot.roundingMode) }}</dd></div>
</dl>
@@ -8,6 +8,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
import { downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import type {
InboundMeasurementDetail,
@@ -314,10 +315,7 @@ async function previewImage(fileId: string | null) {
}
function processStatusLabel(status: string | undefined) {
return ({
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '审核驳回',
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用确认中', INBOUNDING: '入库中', INBOUNDED: '已入库', VOIDED: '已作废',
} as Record<string, string>)[status || ''] || status || '—'
return inboundStatusLabel(status, inboundProcessStatusLabels)
}
watch(
@@ -13,12 +13,14 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundListTab, InboundOrderDetail, InboundOrderSummary } from '../core/types'
import { inboundFeeStatusLabels, inboundLoadingStatusLabels, inboundOrderStatusLabels, inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
type FilterModel = QueryModel & {
customerId: string
@@ -100,7 +102,7 @@ const allQueryFields = computed<QueryField[]>(() => [
{ label: '待计费', value: 'WAIT_PRICING' }, { label: '费用处理中', value: 'FEE_CONFIRMING' },
{ label: '入库处理中', value: 'INBOUNDING' }, { label: '已入库', value: 'INBOUNDED' }, { label: '已作废', value: 'VOIDED' },
] },
{ key: 'orderStatus', type: 'input', width: 200, label: '订单状态码', placeholder: '订单状态码' },
{ key: 'orderStatus', type: 'select', width: 180, label: '订单状态', placeholder: '全部订单状态', options: Object.entries(inboundOrderStatusLabels).map(([value, label]) => ({ value, label })) },
{ key: 'measureReviewStatus', type: 'select', width: 160, label: '测量审核状态', placeholder: '测量审核状态', options: [
{ label: '待审核', value: 'PENDING' }, { label: '审核通过', value: 'APPROVED' }, { label: '审核驳回', value: 'REJECTED' },
] },
@@ -108,7 +110,7 @@ const allQueryFields = computed<QueryField[]>(() => [
{ label: '未计费', value: 'UNPRICED' }, { label: '已试算', value: 'CALCULATED' },
{ label: '已确认', value: 'CONFIRMED' }, { label: '已发送', value: 'SENT' }, { label: '已作废', value: 'VOIDED' },
] },
{ key: 'feeReviewStatus', type: 'input', width: 200, label: '费用审核状态码', placeholder: '费用审核状态码' },
{ key: 'feeReviewStatus', type: 'select', width: 180, label: '费用审核状态', placeholder: '全部审核状态', options: Object.entries(inboundReviewStatusLabels).map(([value, label]) => ({ value, label })) },
{ key: 'customerName', type: 'input', width: 260, label: '客户名称', placeholder: '客户名称' },
{ key: 'destinationCode', type: 'input', width: 220, label: '目的仓编码', placeholder: '目的仓编码' },
{ key: 'holdFlag', type: 'select', width: 160, label: '挂起状态', placeholder: '挂起状态', options: [{ label: '已挂起', value: 'true' }, { label: '未挂起', value: 'false' }] },
@@ -348,7 +350,7 @@ function formatDate(value: string | null) {
}
function text(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function statusLabel(status: string) {
return ({ WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回', WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废' } as Record<string, string>)[status] || status || '未知状态'
return inboundStatusLabel(status, inboundProcessStatusLabels, '未知状态')
}
function statusType(status: string) {
if (status === 'INBOUNDED') return 'success'
@@ -356,13 +358,10 @@ function statusType(status: string) {
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
return 'warning'
}
function codeLabel(status: string | null | undefined, labels: Record<string, string>) { return status ? labels[status] || status : '—' }
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
const feeStatusLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
const loadingStatusLabels: Record<string, string> = { NOT_LOADED: '未配载', LOADING: '配载中', LOADED: '已配载' }
const taxModeLabels: Record<string, string> = { TAX_INCLUDED: '包税', TAX_EXCLUDED: '不包税', DDP: '包税', DDU: '不包税' }
const containerModeLabels: Record<string, string> = { FCL: '整柜', LCL: '拼箱' }
function codeLabel(status: string | null | undefined, labels: Record<string, string>) { return inboundStatusLabel(status, labels) }
const measureReviewLabels = inboundReviewStatusLabels
const feeStatusLabels = inboundFeeStatusLabels
const loadingStatusLabels = inboundLoadingStatusLabels
function resetFilters() {
selectedCustomerId.value = ''
showAdvancedFilters.value = false
@@ -453,10 +452,10 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</span></div></template>
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
<template #feeStatus="{ row }"><div class="status-stack"><span>{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</span><small v-if="(row as InboundOrderSummary).feeReviewStatus">审核:{{ text((row as InboundOrderSummary).feeReviewStatus) }}</small></div></template>
<template #feeStatus="{ row }"><div class="status-stack"><span>{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</span><small v-if="(row as InboundOrderSummary).feeReviewStatus">审核:{{ codeLabel((row as InboundOrderSummary).feeReviewStatus, inboundReviewStatusLabels) }}</small></div></template>
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
@@ -466,10 +465,10 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
<template #containerNo="{ row }">{{ text((row as InboundOrderSummary).containerNo) }}</template>
<template #cabinetNo="{ row }">{{ text((row as InboundOrderSummary).cabinetNo) }}</template>
<template #companyName="{ row }">{{ text((row as InboundOrderSummary).companyName) }}</template>
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template>
<template #businessType="{ row }">{{ firstLegBusinessTypeLabel((row as InboundOrderSummary).businessType) }}</template>
<template #channelName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).channelName) }}</strong><span>{{ text((row as InboundOrderSummary).channelCode) }}</span></div></template>
<template #taxMode="{ row }">{{ codeLabel((row as InboundOrderSummary).taxMode, taxModeLabels) }}</template>
<template #containerMode="{ row }">{{ codeLabel((row as InboundOrderSummary).containerMode, containerModeLabels) }}</template>
<template #taxMode="{ row }">{{ firstLegTaxModeLabel((row as InboundOrderSummary).taxMode) }}</template>
<template #containerMode="{ row }">{{ firstLegContainerModeLabel((row as InboundOrderSummary).containerMode) }}</template>
<template #customsInspection="{ row }">{{ (row as InboundOrderSummary).customsInspection === null ? '—' : (row as InboundOrderSummary).customsInspection ? '是' : '否' }}</template>
<template #sailingAt="{ row }">{{ formatDate((row as InboundOrderSummary).sailingAt) }}</template>
<template #portArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).portArrivalAt) }}</template>
@@ -482,7 +481,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
<template #locationCodes="{ row }">{{ text((row as InboundOrderSummary).locationCodes) }}</template>
<template #volumeWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).volumeWeightKg) }}</template>
<template #actualNetWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualNetWeightKg) }}</template>
<template #destinationType="{ row }">{{ text((row as InboundOrderSummary).destinationType) }}</template>
<template #destinationType="{ row }">{{ firstLegDestinationTypeLabel((row as InboundOrderSummary).destinationType) }}</template>
<template #destinationName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).destinationName) }}</strong><span>{{ text((row as InboundOrderSummary).destinationCode) }}</span></div></template>
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
<template #inboundedAt="{ row }">{{ formatDate((row as InboundOrderSummary).inboundedAt) }}</template>
@@ -46,6 +46,8 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).toContain('原单申报')
expect(wrapper.text()).toContain('设备实测')
expect(wrapper.text()).toContain('设备采集')
expect(wrapper.text()).not.toContain('DEVICE')
expect(wrapper.text()).toContain('称重 OCR')
expect(wrapper.text()).toContain('实测长 × 宽 × 高(cm)')
expect(wrapper.text()).toContain('周长超过渠道限制')
@@ -7,6 +7,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { inboundReviewStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../status'
import {
approveMeasurement,
getMeasurementReviewDetail,
@@ -49,12 +50,7 @@ const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.finalWeightKg !== null).length ?? 0)
const reviewStatusLabel = computed(() => {
if (isPending.value) return '待审核'
if (isRejected.value) return '已驳回'
if (detail.value?.measureReviewStatus === 'APPROVED') return '已通过'
return detail.value?.measureReviewStatus || '未提交'
})
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
const measurementColumns: DataTableColumn[] = [
{ key: 'boxCode', label: '扫描箱号', minWidth: 190, slotName: 'boxCode' },
@@ -83,7 +79,16 @@ function formatDate(value: string | null | undefined) {
function resultLabel(value: string | null | undefined) {
if (value === 'APPROVED') return '审核通过'
if (value === 'REJECTED') return '审核驳回'
return value || '未知结果'
return '未知结果'
}
function recognitionStatusLabel(value: string | null | undefined) {
return inboundStatusLabel(value, weightRecognitionStatusLabels, '待确认')
}
function measurementSourceLabel(value: string | null | undefined) {
if (!value) return '-'
return ({ DEVICE: '设备采集', THIRD_PARTY: '第三方设备', MANUAL: '人工录入' } as Record<string, string>)[value] || '其他来源'
}
function isConflict(error: unknown) {
@@ -247,7 +252,7 @@ onMounted(() => {
<strong>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</strong>
<dl>
<div><dt>已确认图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
<div><dt>测量来源</dt><dd>{{ detail.sourceType || '-' }}</dd></div>
<div><dt>测量来源</dt><dd>{{ measurementSourceLabel(detail.sourceType) }}</dd></div>
</dl>
</article>
</div>
@@ -281,7 +286,7 @@ onMounted(() => {
<div class="section-heading"><div><h3>称重图片识别结果</h3><p>原始 OCR 值不可覆盖,只有已确认且有效的结果会计入实际重量。</p></div><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
<div class="weight-list">
<div v-for="item in detail.weightResults" :key="item.id" class="weight-row">
<div><strong>{{ item.fileId || '未关联文件' }}</strong><span>{{ item.ocrProvider || 'OCR' }} · {{ item.recognitionStatus || '待确认' }}</span><p v-if="item.ocrRawText">OCR 原文:{{ item.ocrRawText }}</p><p v-if="item.adjustmentReason">调整原因:{{ item.adjustmentReason }}</p></div>
<div><strong>{{ item.fileId || '未关联文件' }}</strong><span>{{ item.ocrProvider || 'OCR' }} · {{ recognitionStatusLabel(item.recognitionStatus) }}</span><p v-if="item.ocrRawText">OCR 原文:{{ item.ocrRawText }}</p><p v-if="item.adjustmentReason">调整原因:{{ item.adjustmentReason }}</p></div>
<div><span>原始</span><strong>{{ formatNumber(item.ocrWeightValue, ` ${item.ocrWeightUnit || ''}`) }}</strong></div>
<div><span>调整后</span><strong>{{ formatNumber(item.adjustedWeightValue, ` ${item.adjustedWeightUnit || ''}`) }}</strong></div>
<div><span>最终</span><strong>{{ formatNumber(item.finalWeightKg, ' kg') }}</strong></div>
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { inboundHistoryActionLabel, inboundOrderStatusLabels, inboundProcessStatusLabels, inboundStatusLabel } from './status'
describe('inbound status presentation', () => {
it('translates process and order statuses', () => {
expect(inboundStatusLabel('MEASURE_PENDING', inboundProcessStatusLabels)).toBe('待测量审核')
expect(inboundStatusLabel('WAIT_SPLIT', inboundOrderStatusLabels)).toBe('待分柜')
})
it('does not expose unknown status and action codes', () => {
expect(inboundStatusLabel('NEW_STATUS', inboundProcessStatusLabels)).toBe('未知状态')
expect(inboundHistoryActionLabel('NEW_ACTION')).toBe('状态更新')
})
})
+47
View File
@@ -0,0 +1,47 @@
export const inboundProcessStatusLabels: Record<string, string> = {
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待测量审核', MEASURE_REJECTED: '测量已驳回',
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用处理中', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废',
}
export const inboundOrderStatusLabels: Record<string, string> = {
DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', REJECTED: '已驳回',
PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', PENDING_REVIEW: '待审核',
WAIT_MEASURE: '待测量', INBOUNDED: '已入库', WAIT_SPLIT: '待分柜', LOADING: '装柜中',
LOADED: '装柜完成', DELIVERED: '派送完成',
}
export const inboundReviewStatusLabels: Record<string, string> = {
NOT_SUBMITTED: '未提交', UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回', CONFIRMED: '已确认',
}
export const inboundFeeStatusLabels: Record<string, string> = {
UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废',
}
export const inboundLoadingStatusLabels: Record<string, string> = {
NOT_LOADED: '未配载', WAIT_LOADING: '待配载', EDITING: '预装柜中', LOADING: '配载中', LOADED: '已配载', COMPLETED: '装柜完成',
}
export const inboundNotificationStatusLabels: Record<string, string> = {
NOT_CREATED: '未创建', PENDING: '待发送', SUCCESS: '发送成功', FAILED: '发送失败',
}
export const weightRecognitionStatusLabels: Record<string, string> = {
PENDING: '待识别', PENDING_CONFIRM: '待确认', NEEDS_MANUAL: '需人工录入', CONFIRMED: '已确认',
}
const inboundHistoryActionLabels: Record<string, string> = {
CREATE: '创建入库单', STATUS_UPDATE: '状态更新', MEASURE_SUBMIT: '提交测量', MEASURE_APPROVE: '测量审核通过',
MEASURE_REJECT: '测量审核驳回', FEE_CALCULATE: '费用试算', FEE_CONFIRM: '费用确认',
INBOUND_BEGIN: '开始入库', INBOUND_COMPLETE: '完成入库', VOID: '作废入库单',
}
export function inboundStatusLabel(value: string | null | undefined, labels: Record<string, string>, emptyLabel = '—') {
if (!value) return emptyLabel
return labels[value] || '未知状态'
}
export function inboundHistoryActionLabel(value: string | null | undefined) {
if (!value) return '状态更新'
return inboundHistoryActionLabels[value] || '状态更新'
}
@@ -22,6 +22,7 @@ import {
} from '../api/weightOcr'
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
import WeightImageThumbnail from './WeightImageThumbnail.vue'
import { inboundProcessStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../../status'
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
const emit = defineEmits<{ changed: [] }>()
@@ -73,11 +74,7 @@ function formatConfidence(value: number | null) {
}
function recognitionLabel(status: string) {
return ({
PENDING_CONFIRM: '待确认',
NEEDS_MANUAL: '需人工录入',
CONFIRMED: '已确认',
} as Record<string, string>)[status] || status
return inboundStatusLabel(status, weightRecognitionStatusLabels, '未知状态')
}
function requiresEvidenceReview(row: WeightResultResource) {
@@ -384,7 +381,7 @@ defineExpose({ reload: loadWorkspace })
<template v-else>
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
当前入库状态为 {{ workspace.processStatus }},称重数据仅供查看。
当前入库状态为 {{ inboundStatusLabel(workspace.processStatus, inboundProcessStatusLabels, '未知状态') }},称重数据仅供查看。
</el-alert>
<LoadErrorState
@@ -7,6 +7,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { loadingListStatusLabel } from '../status'
import type { ApiErrorDetails } from '@/shared/api/http'
import type { LoadingDetail, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
@@ -50,10 +51,6 @@ const title = computed(() => {
return `${props.mode === 'preload' ? '预装清单' : '装柜清单'} · ${number}`
})
function statusLabel(status?: string | null) {
return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as Record<string, string>)[status || ''] || status || '—'
}
function actionLabel(action: string) {
return ({
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
@@ -92,7 +89,7 @@ function formatNumber(value?: number | null, digits = 3) {
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
<div><span>柜号</span><strong>{{ summary.internalContainerNo || '—' }}</strong></div>
<div><span>状态</span><StatusBadge :status="statusLabel(summary.status)" /></div>
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
<template v-if="preloadSummary">
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
@@ -132,7 +129,7 @@ function formatNumber(value?: number | null, digits = 3) {
<el-timeline-item v-for="history in detail.histories" :key="history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
<div class="history-entry">
<strong>{{ actionLabel(history.actionType) }}</strong>
<span v-if="history.fromStatus || history.toStatus">{{ statusLabel(history.fromStatus) }} → {{ statusLabel(history.toStatus) }}</span>
<span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span>
<p>{{ history.operatorName || history.operatorId }}<template v-if="history.operationReason"> · {{ history.operationReason }}</template></p>
</div>
</el-timeline-item>
@@ -40,11 +40,8 @@ describe('LoadingListsView', () => {
})
it('renders business labels instead of raw loading-plan status codes', () => {
expect(source).toContain("LOADING: '拣货中'")
expect(source).toContain("COMPLETED: '装柜完成'")
expect(source).toContain(':status="statusLabel(row.status)"')
expect(detailSource).toContain("EDITING: '预装柜中'")
expect(detailSource).toContain(':status="statusLabel(summary.status)"')
expect(source).toContain(':status="loadingListStatusLabel(row.status)"')
expect(detailSource).toContain(':status="loadingListStatusLabel(summary.status)"')
expect(detailSource).not.toContain(':status="summary.status"')
expect(source).not.toContain('仅装柜中允许修改')
})
@@ -70,8 +67,8 @@ describe('LoadingListsView', () => {
})
it('passes translated status text directly to the badge', () => {
expect(source).toContain('<StatusBadge :status="statusLabel(row.status)" />')
expect(source).toContain('<StatusBadge :status="loadingListStatusLabel(row.status)" />')
expect(source).not.toContain('<StatusBadge :status="row.status" />')
expect(detailSource).toContain('<StatusBadge :status="statusLabel(summary.status)" />')
expect(detailSource).toContain('<StatusBadge :status="loadingListStatusLabel(summary.status)" />')
})
})
@@ -13,6 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { loadingListStatusLabel } from '../status'
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
import {
getConfirmedLoadingList,
@@ -110,10 +111,6 @@ function formatNumber(value?: number | null, digits = 3) {
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function statusLabel(status?: string | null) {
return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as Record<string, string>)[status || ''] || status || '—'
}
function queryId(value: unknown) {
return typeof value === 'string' && /^[1-9]\d*$/.test(value) ? value : ''
}
@@ -310,7 +307,7 @@ onMounted(() => {
</template>
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || item.accountId).join('、') || '—' }}</template>
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #status="{ row }"><StatusBadge :status="loadingListStatusLabel(row.status)" /></template>
<template #grossWeight="{ row }">{{ formatNumber(row.totalGrossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber((row as ConfirmedSummary).totalNetWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.totalVolumeCbm) }}</template>
+1
View File
@@ -1,2 +1,3 @@
export * from './api/platformLoadingLists'
export { loadingListRoutes } from './routes'
export { loadingListStatusLabel } from './status'
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { loadingListStatusLabel } from './status'
describe('loading list status labels', () => {
it('translates known states and hides unknown backend enums', () => {
expect(loadingListStatusLabel('EDITING')).toBe('预装柜中')
expect(loadingListStatusLabel('COMPLETED')).toBe('装柜完成')
expect(loadingListStatusLabel('NEW_LOADING_STATE')).toBe('未知状态')
expect(loadingListStatusLabel(null)).toBe('—')
})
})
+11
View File
@@ -0,0 +1,11 @@
const loadingListStatusLabels: Record<string, string> = {
WAIT_LOADING: '待装柜',
EDITING: '预装柜中',
LOADING: '拣货中',
COMPLETED: '装柜完成',
}
export function loadingListStatusLabel(status: string | null | undefined) {
if (!status) return '—'
return loadingListStatusLabels[status] || '未知状态'
}
@@ -41,7 +41,7 @@ onMounted(load)
</template>
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource">
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section>
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section>
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
@@ -385,5 +385,5 @@ export async function listPlatformUnifiedOrderFeeDetails(accountId: string, inbo
}
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || status
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || '未知状态'
}
@@ -20,4 +20,11 @@ describe('OrderFulfillmentTreePanel contract', () => {
expect(source).toContain('暂无拆分分片')
expect(source).toContain('@media (max-width: 720px)')
})
it('translates fragment roles and statuses instead of exposing backend enums', () => {
expect(source).toContain('unifiedOrderFulfillmentRoleLabel(row.splitRole)')
expect(source).toContain('unifiedOrderFulfillmentStatusLabel(row.status, row.splitRole)')
expect(source).not.toContain(':status="row.status')
expect(source).not.toContain('return role ||')
})
})
@@ -5,6 +5,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformOrderFulfillmentTree, type UnifiedOrderFulfillmentTree } from '../api/orderFulfillment'
import { unifiedOrderFulfillmentRoleLabel, unifiedOrderFulfillmentStatusLabel } from '../status'
const props = defineProps<{ tenantId: string; orderId: string; compact?: boolean }>()
const tree = ref<UnifiedOrderFulfillmentTree | null>(null)
@@ -19,12 +20,6 @@ async function load() {
finally { loading.value = false }
}
function roleLabel(role: string | null) {
if (role === 'REMAINDER' || role === 'ACTIVE_REMAINDER') return '当前余量'
if (role === 'LOADED' || role === 'COMPLETED_FRAGMENT' || role === 'SOURCE_COMPLETED') return '已完成分片'
return role || '分片'
}
onMounted(() => { void load() })
</script>
@@ -44,11 +39,11 @@ onMounted(() => { void load() })
<el-alert v-if="tree.historicalTwoChildModel" type="info" :closable="false" show-icon title="该记录使用历史双子单模型,仅兼容展示;新拆分不会再生成已装子单。" />
<el-table :data="tree.fragments" row-key="orderId" empty-text="暂无拆分分片">
<el-table-column label="分片订单" min-width="180"><template #default="{ row }"><strong>{{ row.orderNo || row.orderId }}</strong><small v-if="row.splitSequence != null"> · 第 {{ row.splitSequence }} 次</small></template></el-table-column>
<el-table-column label="类型" width="120"><template #default="{ row }">{{ roleLabel(row.splitRole) }}</template></el-table-column>
<el-table-column label="类型" width="120"><template #default="{ row }">{{ unifiedOrderFulfillmentRoleLabel(row.splitRole) }}</template></el-table-column>
<el-table-column label="本单计划" width="100"><template #default="{ row }">{{ row.plannedPackageCount ?? '—' }} 箱</template></el-table-column>
<el-table-column label="已拣" width="90"><template #default="{ row }">{{ row.pickedPackageCount ?? '—' }}</template></el-table-column>
<el-table-column label="未拣" width="90"><template #default="{ row }">{{ row.unpickedPackageCount ?? '—' }}</template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><StatusBadge :status="row.status || roleLabel(row.splitRole)" /></template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><StatusBadge :status="unifiedOrderFulfillmentStatusLabel(row.status, row.splitRole)" /></template></el-table-column>
<el-table-column label="柜次" min-width="150"><template #default="{ row }">{{ row.containerBookingId || '—' }}</template></el-table-column>
</el-table>
</template>
+9
View File
@@ -4,3 +4,12 @@ export {
type UnifiedOrderFeeDetailResource,
type UnifiedOrderResource,
} from './api/unifiedOrders'
export {
unifiedOrderFulfillmentRoleLabel,
unifiedOrderFulfillmentStatusLabel,
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
unifiedOrderReviewStatusLabel,
unifiedOrderStatusLabel,
} from './status'
@@ -79,7 +79,7 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('v-model:page-size="pageSize"')
expect(source).toContain('slotName: \'orderStatus\'')
expect(source).toContain('slotName: \'measurementReviewStatus\'')
expect(source).toContain("NOT_SUBMITTED: '未提交'")
expect(source).toContain('unifiedOrderReviewStatusLabel(value, \'未录入\')')
expect(source).toContain("{ key: 'customerRemark', prop: 'customerRemark', label: '备注'")
expect(source).toContain("{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '客服内部备注'")
expect(source.match(/<LineClampText v-if="column\.key === 'remark'"/g)).toHaveLength(3)
@@ -156,6 +156,10 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain(':title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain("can('platform:order:note')")
expect(source).toContain('await loadOrders()')
expect(source).toContain('orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus)')
expect(source).toContain('feeReviewLabel(detail.summary.feeReviewStatus)')
expect(source).toContain('unifiedOrderLoadingStatusLabel(detail.summary.transport?.loadingStatus)')
expect(source).not.toContain("value.replaceAll('_', ' ')")
})
it('exposes the single-remainder fulfillment aggregate in row expansion and detail', () => {
@@ -196,6 +200,9 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain("partyValue(detail.order.parties, 'CONSIGNEE'")
expect(source).toContain("cargoTotal(detail.order.cargoes, 'netWeightKg')")
expect(source).toContain('暂无费用数据')
expect(source).toContain('firstLegTransportModeLabel(detail.order.order?.transportMode)')
expect(source).toContain('firstLegPickupMethodLabel(detail.order.order?.pickupMethod)')
expect(source).toContain('firstLegDestinationTypeLabel(detail.order.order?.destinationType)')
})
it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => {
@@ -30,7 +30,22 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import {
firstLegBusinessTypeLabel,
firstLegContainerModeLabel,
firstLegDestinationTypeLabel,
firstLegPickupMethodLabel,
firstLegTaxModeLabel,
firstLegTradeTermLabel,
firstLegTransportModeLabel,
} from '@/shared/utils/firstLegBusinessLabels'
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
import {
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
unifiedOrderReviewStatusLabel,
unifiedOrderStatusLabel,
} from '@/modules/unified-orders/status'
import {
downloadWeightImage,
getInboundMeasurement,
@@ -345,34 +360,29 @@ function displayField(row: UnifiedOrderResource, property: string | undefined) {
if (property.endsWith('At') || property.endsWith('Date')) return formatDate(String(value))
if (property === 'measurementReviewStatus') return measurementStatusLabel(String(value))
if (property === 'internalOrderStatus') return orderStatusLabel(String(value))
if (property === 'feeReviewStatus') return feeReviewLabel(String(value))
if (property === 'loadingStatus') return unifiedOrderLoadingStatusLabel(String(value))
if (property === 'businessType') return firstLegBusinessTypeLabel(String(value))
if (property === 'pickupMethod') return firstLegPickupMethodLabel(String(value))
if (property === 'taxMode') return firstLegTaxModeLabel(String(value))
if (property === 'destinationType') return firstLegDestinationTypeLabel(String(value))
return String(value)
}
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || orderStatusLabel(value) || '状态更新'
return unifiedOrderStatusLabel(value, '状态更新')
}
function historyActionLabel(value: string | null | undefined) {
return ({
CREATE: '创建订单', CREATED: '创建订单', SUBMITTED: '提交订单', APPROVED: '审核通过',
ORDER_APPROVED: '订单审核通过', REJECTED: '审核驳回', MEASURE_APPROVE: '测量审核通过',
MEASURE_REJECT: '测量审核驳回', INBOUND_BEGIN: '开始入库', INBOUND_COMPLETE: '入库完成',
UI_FIXTURE_MEASURE_APPROVE: '测量数据审核通过', UI_FIXTURE_INBOUND_BEGIN: '开始入库',
STATUS_UPDATE: '状态更新',
} as Record<string, string>)[value || ''] || (value ? value.replaceAll('_', ' ') : '状态更新')
return unifiedOrderHistoryActionLabel(value)
}
function measurementStatusLabel(value: string | null | undefined) {
return ({ NOT_SUBMITTED: '未提交', UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' } as Record<string, string>)[value || ''] || value || '未录入'
return unifiedOrderReviewStatusLabel(value, '未录入')
}
function orderStatusLabel(value: string | null | undefined) {
return ({
DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', REJECTED: '已驳回',
PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', PENDING_REVIEW: '待审核',
WAIT_MEASURE: '待测量数据', INBOUNDED: '已入库', WAIT_SPLIT: '待分柜', LOADING: '装柜中',
LOADED: '装柜完成', DELIVERED: '派送完成',
} as Record<string, string>)[value || ''] || value || '—'
return unifiedOrderStatusLabel(value)
}
function cargoValue(cargo: Record<string, unknown>, key: string) {
@@ -405,11 +415,12 @@ function feeNumber(value: number | null | undefined) {
}
function feeReviewLabel(value: string | null | undefined) {
return ({ PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回', NOT_SUBMITTED: '未提交' } as Record<string, string>)[value || ''] || value || '—'
return unifiedOrderReviewStatusLabel(value)
}
function feeDirectionLabel(value: string | null | undefined) {
return ({ RECEIVABLE: '应收', PAYABLE: '应付' } as Record<string, string>)[value || ''] || value || '—'
if (!value) return '—'
return ({ RECEIVABLE: '应收', PAYABLE: '应付' } as Record<string, string>)[value] || '未知方向'
}
async function loadFeeDetails() {
@@ -848,9 +859,9 @@ onMounted(() => {
<div class="panel-title"><div><h2>订单基本信息</h2><p>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</p></div><Box :size="19" /></div>
<dl class="order-info-grid">
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ detail.order.order?.transportMode || '—' }}</dd></div>
<div><dt>运输方式</dt><dd>{{ firstLegTransportModeLabel(detail.order.order?.transportMode) }}</dd></div>
<div><dt>当前状态</dt><dd><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /></dd></div>
<div><dt>内部订单状态</dt><dd>{{ detail.summary.internalOrderStatus || '—' }}</dd></div>
<div><dt>内部订单状态</dt><dd>{{ orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus) }}</dd></div>
<div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div>
<div><dt>收货仓地址</dt><dd>{{ detail.order.order?.receivingWarehouseAddress || '—' }}</dd></div>
<div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div>
@@ -860,13 +871,13 @@ onMounted(() => {
<div><dt>目的仓联系人</dt><dd>{{ detail.summary.destinationContact || '—' }}</dd></div>
<div><dt>目的仓联系人电话</dt><dd>{{ detail.summary.destinationContactPhone || '—' }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }}</dd></div>
<div><dt>业务类型</dt><dd>{{ detail.summary.businessType || '—' }}</dd></div>
<div><dt>贸易条款</dt><dd>{{ detail.order.order?.tradeTerm || '—' }}</dd></div>
<div><dt>买单 / 退税</dt><dd>{{ detail.order.order?.taxMode || '—' }}</dd></div>
<div><dt>费用审核状态</dt><dd>{{ detail.summary.feeReviewStatus || '—' }}</dd></div>
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(detail.summary.businessType) }}</dd></div>
<div><dt>贸易条款</dt><dd>{{ firstLegTradeTermLabel(detail.order.order?.tradeTerm) }}</dd></div>
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(detail.order.order?.taxMode) }}</dd></div>
<div><dt>费用审核状态</dt><dd>{{ feeReviewLabel(detail.summary.feeReviewStatus) }}</dd></div>
<div><dt>是否扣货</dt><dd>{{ displayBoolean(detail.summary.holdCargo) }}</dd></div>
<div><dt>扣货理由</dt><dd>{{ detail.summary.holdReason || '—' }}</dd></div>
<div><dt>提货方式</dt><dd>{{ detail.order.order?.pickupMethod || '—' }}</dd></div>
<div><dt>提货方式</dt><dd>{{ firstLegPickupMethodLabel(detail.order.order?.pickupMethod) }}</dd></div>
<div><dt>提货时间</dt><dd>{{ formatDate(detail.summary.pickupAt) }}</dd></div>
<div><dt>到仓时间</dt><dd>{{ detail.order.order?.receivingTime || '—' }}</dd></div>
<div><dt>货物到仓时间</dt><dd>{{ formatDate(detail.summary.expectedArrivalAt || detail.summary.inboundCompletedAt) }}</dd></div>
@@ -877,9 +888,9 @@ onMounted(() => {
<div><dt>船名 / 航次</dt><dd>{{ detail.summary.transport?.vesselName || '—' }} / {{ detail.summary.transport?.voyageNo || '—' }}</dd></div>
<div><dt>开船时间</dt><dd>{{ formatDate(detail.summary.transport?.sailingAt) }}</dd></div>
<div><dt>到港时间</dt><dd>{{ formatDate(detail.summary.transport?.portArrivalAt) }}</dd></div>
<div><dt>配载状态</dt><dd>{{ detail.summary.transport?.loadingStatus || '—' }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ detail.order.order?.destinationType || '—' }}</dd></div>
<div><dt>是否整柜</dt><dd>{{ detail.order.order?.containerMode || '—' }}</dd></div>
<div><dt>配载状态</dt><dd>{{ unifiedOrderLoadingStatusLabel(detail.summary.transport?.loadingStatus) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ firstLegDestinationTypeLabel(detail.order.order?.destinationType) }}</dd></div>
<div><dt>是否整柜</dt><dd>{{ firstLegContainerModeLabel(detail.order.order?.containerMode) }}</dd></div>
<div><dt>商检验</dt><dd>{{ displayBoolean(detail.order.order?.customsInspection) }}</dd></div>
<div><dt>总箱数</dt><dd>{{ detail.summary.totalPackageCount ?? '—' }} 箱</dd></div>
<div><dt>总托盘数</dt><dd>{{ detail.summary.totalPalletCount ?? '—' }}</dd></div>
@@ -1097,7 +1108,7 @@ onMounted(() => {
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || (row as UnifiedOrderResource).transport?.loadingStatus || '—' }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || unifiedOrderLoadingStatusLabel((row as UnifiedOrderResource).transport?.loadingStatus) }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import {
unifiedOrderFulfillmentRoleLabel,
unifiedOrderFulfillmentStatusLabel,
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
unifiedOrderReviewStatusLabel,
unifiedOrderStatusLabel,
} from './status'
describe('unified order status labels', () => {
it('maps order, review and loading statuses to domain-specific Chinese', () => {
expect(unifiedOrderStatusLabel('WAIT_SPLIT')).toBe('待分柜')
expect(unifiedOrderReviewStatusLabel('APPROVED')).toBe('审核通过')
expect(unifiedOrderLoadingStatusLabel('LOADED')).toBe('已配载')
})
it('never exposes unknown backend enums', () => {
expect(unifiedOrderStatusLabel('NEW_ORDER_STATE')).toBe('未知状态')
expect(unifiedOrderReviewStatusLabel('NEW_REVIEW_STATE')).toBe('未知状态')
expect(unifiedOrderLoadingStatusLabel('NEW_LOADING_STATE')).toBe('未知状态')
expect(unifiedOrderHistoryActionLabel('NEW_ACTION')).toBe('状态更新')
expect(unifiedOrderFulfillmentRoleLabel('NEW_ROLE')).toBe('未知类型')
expect(unifiedOrderFulfillmentStatusLabel('NEW_FRAGMENT_STATE', 'REMAINDER')).toBe('未知状态')
})
it('uses the fragment role when a fulfillment status is absent', () => {
expect(unifiedOrderFulfillmentStatusLabel(null, 'ACTIVE_REMAINDER')).toBe('当前余量')
expect(unifiedOrderFulfillmentStatusLabel(undefined, 'COMPLETED_FRAGMENT')).toBe('已完成分片')
})
})
+98
View File
@@ -0,0 +1,98 @@
const orderStatusLabels: Record<string, string> = {
DRAFT: '草稿',
SUBMITTED: '待审核',
APPROVED: '已审核',
ACCEPTED: '已受理',
REJECTED: '已驳回',
PROCESSING: '处理中',
COMPLETED: '已完成',
CANCELLED: '已取消',
PENDING_REVIEW: '待审核',
WAIT_MEASURE: '待测量数据',
INBOUNDED: '已入库',
WAIT_SPLIT: '待分柜',
LOADING: '装柜中',
LOADED: '装柜完成',
DELIVERED: '派送完成',
}
const reviewStatusLabels: Record<string, string> = {
NOT_SUBMITTED: '未提交',
UNRECORDED: '未录入',
PENDING: '待审核',
APPROVED: '审核通过',
REJECTED: '审核驳回',
CONFIRMED: '已确认',
}
const loadingStatusLabels: Record<string, string> = {
NOT_LOADED: '未配载',
WAIT_LOADING: '待配载',
EDITING: '预装柜中',
LOADING: '配载中',
LOADED: '已配载',
COMPLETED: '装柜完成',
}
const historyActionLabels: Record<string, string> = {
CREATE: '创建订单',
CREATED: '创建订单',
SUBMITTED: '提交订单',
APPROVED: '审核通过',
ORDER_APPROVED: '订单审核通过',
REJECTED: '审核驳回',
MEASURE_APPROVE: '测量审核通过',
MEASURE_REJECT: '测量审核驳回',
INBOUND_BEGIN: '开始入库',
INBOUND_COMPLETE: '入库完成',
UI_FIXTURE_MEASURE_APPROVE: '测量数据审核通过',
UI_FIXTURE_INBOUND_BEGIN: '开始入库',
STATUS_UPDATE: '状态更新',
}
const fulfillmentRoleLabels: Record<string, string> = {
REMAINDER: '当前余量',
ACTIVE_REMAINDER: '当前余量',
LOADED: '已完成分片',
COMPLETED_FRAGMENT: '已完成分片',
SOURCE_COMPLETED: '已完成分片',
}
function normalized(value: string | null | undefined) {
return value?.trim().toUpperCase() || ''
}
function statusLabel(value: string | null | undefined, labels: Record<string, string>, emptyLabel = '—') {
const status = normalized(value)
if (!status) return emptyLabel
return labels[status] || '未知状态'
}
export function unifiedOrderStatusLabel(value: string | null | undefined, emptyLabel = '—') {
return statusLabel(value, orderStatusLabels, emptyLabel)
}
export function unifiedOrderReviewStatusLabel(value: string | null | undefined, emptyLabel = '—') {
return statusLabel(value, reviewStatusLabels, emptyLabel)
}
export function unifiedOrderLoadingStatusLabel(value: string | null | undefined, emptyLabel = '—') {
return statusLabel(value, loadingStatusLabels, emptyLabel)
}
export function unifiedOrderHistoryActionLabel(value: string | null | undefined) {
const action = normalized(value)
if (!action) return '状态更新'
return historyActionLabels[action] || '状态更新'
}
export function unifiedOrderFulfillmentRoleLabel(value: string | null | undefined) {
const role = normalized(value)
if (!role) return '分片'
return fulfillmentRoleLabels[role] || '未知类型'
}
export function unifiedOrderFulfillmentStatusLabel(status: string | null | undefined, role: string | null | undefined) {
if (!status) return unifiedOrderFulfillmentRoleLabel(role)
return unifiedOrderStatusLabel(status)
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import {
firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel,
firstLegPickupMethodLabel, firstLegTaxModeLabel, firstLegTradeTermLabel, firstLegTransportModeLabel,
} from './firstLegBusinessLabels'
describe('first-leg business labels', () => {
it('translates business codes using the customer order contract', () => {
expect(firstLegTransportModeLabel('SEA')).toBe('海运')
expect(firstLegTradeTermLabel('FOB')).toBe('FOB(离岸价)')
expect(firstLegPickupMethodLabel('PICKUP')).toBe('平台上门提货')
expect(firstLegTaxModeLabel('BUY_DECLARATION')).toBe('买单报关')
expect(firstLegContainerModeLabel('FULL_CONTAINER')).toBe('整柜')
expect(firstLegDestinationTypeLabel('OVERSEAS')).toBe('海外仓')
expect(firstLegBusinessTypeLabel('EXPORT')).toBe('出口业务')
})
it('does not expose unknown backend codes', () => {
expect(firstLegTransportModeLabel('NEW_MODE')).toBe('未知类型')
expect(firstLegTaxModeLabel(null)).toBe('—')
})
})
@@ -0,0 +1,32 @@
const businessTypeLabels: Record<string, string> = {
EXPORT: '出口业务', FIRST_LEG: '头程物流', FBA: 'FBA 头程', FBA_SEA: 'FBA 海运',
}
const transportModeLabels: Record<string, string> = {
SEA: '海运', AIR: '空运', RAIL: '铁路运输', TRUCK: '卡车运输',
}
const tradeTermLabels: Record<string, string> = {
EXW: 'EXW(工厂交货)', FCA: 'FCA(货交承运人)', FOB: 'FOB(离岸价)',
CFR: 'CFR(成本加运费)', CIF: 'CIF(成本、保险费加运费)',
DDU: 'DDU(未完税交货)', DDP: 'DDP(完税后交货)',
}
const pickupMethodLabels: Record<string, string> = {
SELF_EXPRESS: '自行快递到仓', PICKUP: '平台上门提货', SELF_TRUCK: '自行送货到仓', WAREHOUSE: '送至平台仓库',
}
const taxModeLabels: Record<string, string> = {
BUY_DECLARATION: '买单报关', TAX_REFUND: '退税报关', TAX_INCLUDED: '包税', TAX_EXCLUDED: '不包税', DDP: '包税', DDU: '不包税',
}
const containerModeLabels: Record<string, string> = { FULL_CONTAINER: '整柜', FCL: '整柜', LCL: '拼箱' }
const destinationTypeLabels: Record<string, string> = { FBA: 'FBA 仓', OVERSEAS: '海外仓', PRIVATE: '私人地址' }
function label(value: string | null | undefined, labels: Record<string, string>) {
if (!value) return '—'
return labels[value.trim().toUpperCase()] || '未知类型'
}
export const firstLegBusinessTypeLabel = (value: string | null | undefined) => label(value, businessTypeLabels)
export const firstLegTransportModeLabel = (value: string | null | undefined) => label(value, transportModeLabels)
export const firstLegTradeTermLabel = (value: string | null | undefined) => label(value, tradeTermLabels)
export const firstLegPickupMethodLabel = (value: string | null | undefined) => label(value, pickupMethodLabels)
export const firstLegTaxModeLabel = (value: string | null | undefined) => label(value, taxModeLabels)
export const firstLegContainerModeLabel = (value: string | null | undefined) => label(value, containerModeLabels)
export const firstLegDestinationTypeLabel = (value: string | null | undefined) => label(value, destinationTypeLabels)