优化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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: {} })
|
||||
|
||||
|
||||
@@ -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:')
|
||||
})
|
||||
})
|
||||
@@ -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('其他来源')
|
||||
})
|
||||
})
|
||||
@@ -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, '结果未知')
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('状态更新')
|
||||
})
|
||||
})
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from './api/platformLoadingLists'
|
||||
export { loadingListRoutes } from './routes'
|
||||
export { loadingListStatusLabel } from './status'
|
||||
|
||||
@@ -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('—')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('已完成分片')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user