Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
24 changed files with 983 additions and 75 deletions
Showing only changes of commit fb542e0b6f - Show all commits
@@ -134,6 +134,24 @@ describe('CustomerDetailView', () => {
expect(document.body.textContent).not.toContain('计费规则尚未接入')
})
it('does not expose unknown order status codes', async () => {
const result = await listPlatformFirstLegOrders({ tenantId: String(tenant.id), page: 1, size: 10 })
vi.mocked(listPlatformFirstLegOrders).mockResolvedValueOnce({
...result,
records: result.records.map((order) => ({ ...order, status: 'LEGACY_STATUS' as PlatformFirstLegOrderResource['status'] })),
})
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const ordersTab = [...document.querySelectorAll<HTMLButtonElement>('.detail-tabs button')]
.find((button) => button.textContent?.trim() === '运单记录')
ordersTab?.click()
await flushPromises()
expect(document.body.textContent).toContain('未知状态')
expect(document.body.textContent).not.toContain('LEGACY_STATUS')
})
it('opens an authenticated qualification preview', async () => {
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
@@ -108,7 +108,7 @@ function statusMeta(status: PlatformTenantStatus) {
return { label: '已停用', type: 'info' as const }
}
function qualificationLabel(type: string) {
return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record<string, string>)[type] || type
return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record<string, string>)[type] || '未知资质类型'
}
function qualificationStatus(status: PlatformTenantQualification['status']) {
if (status === 'APPROVED') return { label: '已通过', type: 'success' as const }
@@ -121,7 +121,7 @@ function qualificationName(item: PlatformTenantQualification) {
catch { return `${qualificationLabel(item.qualificationType)}文件` }
}
function orderStatusLabel(status: PlatformFirstLegOrderStatus) {
return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record<PlatformFirstLegOrderStatus, string>)[status] || status
return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record<PlatformFirstLegOrderStatus, string>)[status] || '未知状态'
}
function transportLabel(order: PlatformFirstLegOrderResource) {
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL'
+34 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, getFinanceFeeSource, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
@@ -90,6 +90,39 @@ describe('finance api', () => {
expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4, currency: 'CNY' })
})
it('uses the frozen G3 reconciliation create and action contracts', async () => {
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, feeItemName: '运费', statementAmount: 100 }], paymentHistory: [], revisions: [] }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
const created = await createReconciliationStatement({ customerId: '10001', currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: '2026-08-01', periodEnd: '2026-08-31' })
const loaded = await getReconciliationStatement('501')
await actionReconciliationStatement('501', { action: 'SUBMIT', expectedVersion: 0 })
expect(post.mock.calls[0]).toEqual(['/platform/finance/v1/statements', expect.objectContaining({ customerId: '10001', currency: 'CNY', generationMode: 'BY_CUTOFF' }), { headers: { 'Idempotency-Key': expect.any(String) } }])
expect(post.mock.calls[1]).toEqual(['/platform/finance/v1/reconciliation-statements/501/actions', { action: 'SUBMIT', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.any(String) } }])
expect(get).toHaveBeenCalledWith('/platform/finance/v1/reconciliation-statements/501')
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, lines: [{ statementLineId: '601', sourceFeeId: '42', amount: 100 }] })
expect(loaded.revisions).toEqual([])
})
it('uses the frozen G4 settlement create, detail and payment contracts', async () => {
const detail = { summary: { settlementId: 701, settlementNo: 'AP-701', customerId: 10001, supplierId: 81, reconciliationStatementId: 501, direction: 'PAYABLE', totalAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 0 }, lines: [], transactions: [], revisions: [] }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
await createReceivableSettlement({ reconciliationStatementId: '501', currency: 'CNY' })
await createPayableSettlement({ customerId: '10001', supplierId: '81', feeSourceIds: ['42'], dueDate: '2026-09-10', currency: 'CNY' })
const loaded = await getFinanceSettlement('701')
await createFinanceSettlementPayment('701', { transactionDate: '2026-08-11', amount: 200, currency: 'CNY', method: 'BANK_TRANSFER', voucherFileIds: ['9001'], expectedVersion: 0 })
expect(post.mock.calls[0]?.[0]).toBe('/platform/finance/v1/receivable-settlements')
expect(post.mock.calls[1]?.[0]).toBe('/platform/finance/v1/payable-settlements')
expect(get).toHaveBeenCalledWith('/platform/finance/v1/settlements/701')
expect(post.mock.calls[2]).toEqual(['/platform/finance/v1/settlements/701/transactions', expect.objectContaining({ amount: 200, voucherFileIds: ['9001'], expectedVersion: 0 }), { headers: { 'Idempotency-Key': expect.any(String) } }])
expect(loaded).toMatchObject({ id: '701', settlementNo: 'AP-701', customerId: '10001', supplierId: '81', reconciliationStatementId: '501', direction: 'PAYABLE', amount: 200, status: 'ISSUED' })
})
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: {} })
+61 -18
View File
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceSettlementDetail, FinanceStatementReadModel, FinanceVoucherResource } from '../types'
import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel, FinanceVoucherResource, ReconciliationActionPayload } from '../types'
const basePath = '/platform/finance/v1'
export const financePhase1Capabilities = {
@@ -8,20 +8,15 @@ export const financePhase1Capabilities = {
feeSourceDetail: true,
feeSourceReview: true,
feeSourceSettlementInclusion: true,
reconciliationCreate: false,
reconciliationActions: false,
receivableSettlementCreate: false,
payableSettlementCreate: false,
settlementDetail: false,
settlementPayment: false,
reconciliationCreate: true,
reconciliationActions: true,
receivableSettlementCreate: true,
payableSettlementCreate: true,
settlementDetail: true,
settlementPayment: true,
} as const
function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
type RawResource = Record<string, unknown>
export class FinanceCapabilityBlockedError extends Error {
readonly code = 'FINANCE_NOT_IMPLEMENTED'
constructor(operation: string) { super(`接口尚未提供,暂不可操作:${operation}`); this.name = 'FinanceCapabilityBlockedError' }
}
function stringValue(value: unknown) { return value == null ? '' : String(value) }
function numberValue(value: unknown) { return Number(value ?? 0) }
function jsonRecord(value: unknown): RawResource {
@@ -46,6 +41,7 @@ function normalizeBill(item: RawResource, customerId = stringValue(item.tenant_i
}
function normalizeBillLine(item: RawResource): FinanceBillLine {
return {
statementLineId: stringValue(item.id ?? item.statementLineId),
sourceFeeId: stringValue(item.source_id ?? item.sourceId ?? item.sourceFeeId), sourceFeeVersion: numberValue(item.source_version ?? item.sourceVersion ?? item.sourceFeeVersion) || 1,
orderId: item.order_id == null && item.orderId == null ? null : stringValue(item.order_id ?? item.orderId),
inboundId: item.inbound_id == null && item.inboundId == null ? null : stringValue(item.inbound_id ?? item.inboundId),
@@ -129,12 +125,59 @@ export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: str
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') }
export async function createPayableSettlement(payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createPayableSettlement') }
export async function getFinanceSettlement(settlementId: string): Promise<FinanceSettlementDetail> { throw new FinanceCapabilityBlockedError('getSettlement') }
export async function createFinanceSettlementPayment(settlementId: string, payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createSettlementPayment') }
function normalizeReconciliationDetail(item: RawResource): FinanceReconciliationDetail {
const lines = (Array.isArray(item.lines) ? item.lines : []).map((entry) => {
const row = entry as RawResource
return { statementLineId: stringValue(row.statementLineId), sourceFeeId: stringValue(row.feeSourceId), sourceFeeVersion: numberValue(row.sourceVersion), orderId: null, inboundId: row.inboundId == null ? null : stringValue(row.inboundId), feeItemName: stringValue(row.feeItemName), amount: numberValue(row.statementAmount) }
})
return {
id: stringValue(item.statementId), billNo: stringValue(item.statementNo), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
billDate: stringValue(item.periodEnd), dueDate: '', periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: numberValue(item.creditDays), currency: 'CNY',
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), lines,
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), createdBy: stringValue(row.createdBy), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), occurredAt: stringValue(row.occurredAt) } }),
}
}
function normalizeSettlementDetail(item: RawResource): FinanceSettlementDetail {
const summary = item.summary && typeof item.summary === 'object' ? item.summary as RawResource : item
return {
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
amount: numberValue(summary.totalAmount ?? summary.amount), paidAmount: numberValue(summary.paidAmount), outstandingAmount: numberValue(summary.outstandingAmount), currency: 'CNY', status: stringValue(summary.status), version: numberValue(summary.version),
lines: (Array.isArray(item.lines) ? item.lines : []).map((line) => normalizeFeeSourceDetail(line as RawResource)),
transactions: (Array.isArray(item.transactions) ? item.transactions : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], remark: stringValue(row.remark) || undefined } }),
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, occurredAt: stringValue(row.occurredAt) } }),
}
}
export async function createReconciliationStatement(payload: CreateReconciliationPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/statements`, payload, mutationHeaders('reconciliation-create'))
return normalizeReconciliationDetail(unwrapApiResponse(response))
}
export async function actionReconciliationStatement(statementId: string, payload: ReconciliationActionPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/reconciliation-statements/${statementId}/actions`, payload, mutationHeaders('reconciliation-action'))
return normalizeReconciliationDetail(unwrapApiResponse(response))
}
export async function getReconciliationStatement(statementId: string): Promise<FinanceReconciliationDetail> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/reconciliation-statements/${statementId}`)
return normalizeReconciliationDetail(unwrapApiResponse(response))
}
export async function createReceivableSettlement(payload: CreateReceivableSettlementPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/receivable-settlements`, payload, mutationHeaders('settlement-ar-create'))
return normalizeSettlementDetail(unwrapApiResponse(response))
}
export async function createPayableSettlement(payload: CreatePayableSettlementPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/payable-settlements`, payload, mutationHeaders('settlement-ap-create'))
return normalizeSettlementDetail(unwrapApiResponse(response))
}
export async function getFinanceSettlement(settlementId: string): Promise<FinanceSettlementDetail> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/settlements/${settlementId}`)
return normalizeSettlementDetail(unwrapApiResponse(response))
}
export async function createFinanceSettlementPayment(settlementId: string, payload: CreateSettlementPaymentPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/settlements/${settlementId}/transactions`, payload, mutationHeaders('settlement-payment'))
return normalizeSettlementDetail(unwrapApiResponse(response))
}
export async function listFinanceStatements(customerId: string): Promise<FinanceStatementReadModel[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/statements`, { params: { customerId } })
@@ -1,27 +1,215 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
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 { getFinanceBill, listFinanceStatements } from '../api/finance'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceBillDetail, FinanceStatementReadModel } from '../types'
const access = useAccessPreviewStore(); const customers = ref<PlatformTenantResource[]>([]); const filters = ref<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.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() { customerError.value = null; 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.value) { filters.value = { ...model }; rows.value = []; error.value = null; if (!filters.value.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.value.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, '对账单详情加载失败,请重试') } }
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
statementLineId: string
feeItemName: string
originalAmount: number
adjustedAmount: number
reason: string
}
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = ref<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<FinanceReconciliationDetail | null>(null)
const detailError = ref<ApiErrorDetails | null>(null)
const createOpen = ref(false)
const createBusy = ref(false)
const actionBusy = ref(false)
const createForm = ref({ periodStart: new Date().toISOString().slice(0, 8) + '01', periodEnd: new Date().toISOString().slice(0, 10), remark: '' })
const adjustOpen = ref(false)
const adjustBusy = ref(false)
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
const canView = computed(() => access.can('platform:finance:bill:view'))
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
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: 280, 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() {
customerError.value = null
try {
const result = await listPlatformTenants({ page: 1, size: 200 })
customers.value = result.records
} catch (cause) {
customerError.value = getApiErrorDetails(cause, '客户列表加载失败')
}
}
async function load(model: QueryModel = filters.value) {
filters.value = { ...model }
rows.value = []
error.value = null
if (!filters.value.customerId) return
loading.value = true
try { rows.value = await listFinanceStatements(String(filters.value.customerId)) }
catch (cause) { error.value = getApiErrorDetails(cause, '客户对账单加载失败,请重试') }
finally { loading.value = false }
}
async function fetchDetail(statementId: string) {
return getReconciliationStatement(statementId)
}
async function openDetail(row: FinanceStatementReadModel) {
drawerOpen.value = true
detail.value = null
detailError.value = null
try { detail.value = await fetchDetail(row.id) }
catch (cause) { detailError.value = getApiErrorDetails(cause, '对账单详情加载失败,请重试') }
}
async function createStatement() {
if (!filters.value.customerId) return ElMessage.warning('请先选择客户')
createBusy.value = true
try {
await createReconciliationStatement({ customerId: String(filters.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
createOpen.value = false
ElMessage.success('对账单已生成')
await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') }
finally { createBusy.value = false }
}
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
? await ElMessageBox.prompt('请输入操作原因', '对账单操作', { inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
: undefined
if (reason === null) return
actionBusy.value = true
try {
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
ElMessage.success('对账单状态已更新')
await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
finally { actionBusy.value = false }
}
async function openAdjustment(row: FinanceStatementReadModel) {
adjustBusy.value = true
try {
const current = await fetchDetail(row.id)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
adjustLines.value = current.lines.flatMap((line) => {
const statementLineId = line.statementLineId
return statementLineId ? [{ selected: false, statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : []
})
adjustOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
async function submitAdjustment() {
const statement = adjustStatement.value
const selected = adjustLines.value.filter((line) => line.selected)
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细')
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
adjustBusy.value = true
try {
await actionReconciliationStatement(statement.id, {
action: 'ADJUST',
reason: adjustReason.value.trim(),
expectedVersion: statement.version,
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load()
await openDetail(statement)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
finally { adjustBusy.value = false }
}
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 #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</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></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 v-if="canCreate" type="primary" @click="createOpen = true"><Plus :size="15" />生成对账单</el-button></template>
<template #actions="{ row }">
<el-button link type="primary" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
<el-button v-if="row.status === 'DRAFT' && canCreate" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && (canReview || canVoid)" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
<el-button link aria-label="更多对账单操作" title="更多对账单操作"><MoreHorizontal :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canReview" command="REJECT"><XCircle :size="15" />驳回</el-dropdown-item><el-dropdown-item v-if="canVoid" command="VOID" divided><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button v-if="row.status === 'APPROVED' && canIssue" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
<el-button v-if="row.status === 'ISSUED' && canAdjust" link :loading="adjustBusy" @click="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" link type="danger" :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
</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="客户对账单详情" size="min(820px, 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-table :data="detail.lines" size="small"><el-table-column prop="feeItemName" label="费用项目" /><el-table-column prop="amount" label="账单金额(CNY)" /></el-table>
<el-table v-if="detail.revisions.length" :data="detail.revisions" size="small"><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column prop="action" label="动作" width="130" /><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /></el-table>
</template>
</AppDrawer>
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(520px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
<AppDialog v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<el-form label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit /></el-form-item></el-form>
<el-table :data="adjustLines" size="small" max-height="420"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" /></template></el-table-column></el-table>
</AppDialog>
</AdminPage>
</template>
<style scoped>.finance-statements-page{min-width:0}</style>
<style scoped>
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
</style>
@@ -1,19 +1,39 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { ElMessage, type UploadFile } from 'element-plus'
import { CreditCard, Eye, HandCoins, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
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 { listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
import {
createFinanceSettlementPayment,
createFinanceReceipt,
createPayableSettlement,
createReceivableSettlement,
getFinanceSettlement,
listFinanceFeePool,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
listFinanceStatements,
uploadFinanceVoucher,
} from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types'
import type {
FinanceFeePoolItem,
FinancePayableSettlement,
FinanceReceivableSettlement,
FinanceSettlementDetail,
FinanceStatementReadModel,
} from '../types'
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
const access = useAccessPreviewStore()
@@ -23,41 +43,222 @@ const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>(
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const customerError = ref<ApiErrorDetails | null>(null)
const drawerOpen = ref(false)
const selected = ref<FinanceReceivableSettlement | FinancePayableSettlement | null>(null)
const detailOpen = ref(false)
const detail = ref<FinanceSettlementDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const createOpen = ref(false)
const createBusy = ref(false)
const statementOptions = ref<FinanceStatementReadModel[]>([])
const feeOptions = ref<FinanceFeePoolItem[]>([])
const supplierOptions = ref<PlatformPartnerResource[]>([])
const createForm = ref({ reconciliationStatementId: '', supplierId: '', feeSourceIds: [] as string[], dueDate: '', })
const paymentOpen = ref(false)
const paymentBusy = ref(false)
const paymentFile = ref<File | null>(null)
const paymentForm = ref({ transactionDate: new Date().toISOString().slice(0, 10), amount: 0, method: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', referenceNo: '', remark: '' })
const receiptOpen = ref(false)
const receiptBusy = ref(false)
const receiptFile = ref<File | null>(null)
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
const canView = computed(() => access.can(viewPermission.value))
const canCreate = computed(() => access.can(createPermission.value))
const canPay = computed(() => props.kind === 'payable' && access.can('platform:finance:payment:create'))
const canReceive = computed(() => props.kind === 'receivable' && access.can('platform:finance:receipt:create'))
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
const createLabel = computed(() => props.kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
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: '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() { customerError.value = null; 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.value) { filters.value = { ...model }; rows.value = []; error.value = null; if (!filters.value.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.value.customerId)) : await listFinancePayableSettlements(String(filters.value.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 }
? [{ key: 'actions', label: '操作', width: 190, 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: 190, 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() {
customerError.value = null
try { customers.value = (await listPlatformTenants({ page: 1, size: 200 })).records }
catch (cause) { customerError.value = getApiErrorDetails(cause, '客户列表加载失败') }
}
async function load(model: QueryModel = filters.value) {
filters.value = { ...model }
rows.value = []
error.value = null
if (!filters.value.customerId) return
loading.value = true
try {
rows.value = props.kind === 'receivable'
? await listFinanceReceivableSettlements(String(filters.value.customerId))
: await listFinancePayableSettlements(String(filters.value.customerId))
} catch (cause) { error.value = getApiErrorDetails(cause, `${title.value}加载失败,请重试`) }
finally { loading.value = false }
}
async function openDetail(row: unknown) {
const settlement = row as FinanceReceivableSettlement | FinancePayableSettlement
detailOpen.value = true
detail.value = null
detailError.value = null
detailLoading.value = true
try { detail.value = await getFinanceSettlement(settlement.id) }
catch (cause) { detailError.value = getApiErrorDetails(cause, `${title.value}详情加载失败`) }
finally { detailLoading.value = false }
}
async function openCreate() {
const customerId = String(filters.value.customerId || '')
if (!customerId) {
ElMessage.warning('请先选择客户')
return
}
createForm.value = { reconciliationStatementId: '', supplierId: '', feeSourceIds: [], dueDate: '' }
createOpen.value = true
error.value = null
try {
if (props.kind === 'receivable') {
statementOptions.value = (await listFinanceStatements(customerId)).filter((item) => item.status === 'ISSUED')
} else {
const [fees, suppliers] = await Promise.all([
listFinanceFeePool(customerId, 'PAYABLE'),
listPlatformPartners({ page: 1, size: 200, type: 'SUPPLIER', status: 'ACTIVE' }),
])
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
supplierOptions.value = suppliers.records
}
} catch (cause) { error.value = getApiErrorDetails(cause, '结算来源加载失败') }
}
async function submitCreate() {
const customerId = String(filters.value.customerId || '')
if (props.kind === 'receivable' && !createForm.value.reconciliationStatementId) return ElMessage.warning('请选择已出账对账单')
if (props.kind === 'payable' && (!createForm.value.supplierId || !createForm.value.dueDate || !createForm.value.feeSourceIds.length)) return ElMessage.warning('请完整填写供应商、到期日和费用来源')
createBusy.value = true
try {
if (props.kind === 'receivable') {
await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
} else {
await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
}
createOpen.value = false
ElMessage.success(`${title.value}已生成`)
await load()
} catch (cause) { error.value = getApiErrorDetails(cause, `${title.value}生成失败`) }
finally { createBusy.value = false }
}
function openPayment(row: unknown) {
const settlement = row as FinancePayableSettlement
detail.value = { id: settlement.id, settlementNo: settlement.settlementNo, customerId: settlement.customerId, direction: 'PAYABLE', amount: settlement.payableAmount, paidAmount: settlement.paidAmount, outstandingAmount: settlement.outstandingAmount, currency: 'CNY', status: settlement.status, version: settlement.version, lines: [], transactions: [], revisions: [] }
paymentFile.value = null
paymentForm.value = { transactionDate: new Date().toISOString().slice(0, 10), amount: settlement.outstandingAmount, method: 'BANK_TRANSFER', referenceNo: '', remark: '' }
paymentOpen.value = true
}
function selectPaymentFile(file: UploadFile) { paymentFile.value = file.raw ?? null }
async function openReceipt(row: unknown) {
const settlement = row as FinanceReceivableSettlement
receiptBusy.value = true
try {
const current = await getFinanceSettlement(settlement.id)
if (!current.reconciliationStatementId) throw new Error('应收结算单缺少对账单关联')
detail.value = current
receiptFile.value = null
receiptForm.value = { receivedDate: new Date().toISOString().slice(0, 10), amount: current.outstandingAmount, paymentMethod: 'BANK_TRANSFER', remark: '' }
receiptOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '应收结算单加载失败,无法登记收款') }
finally { receiptBusy.value = false }
}
function selectReceiptFile(file: UploadFile) { receiptFile.value = file.raw ?? null }
async function submitReceipt() {
const current = detail.value
if (!current?.reconciliationStatementId || !receiptFile.value || receiptForm.value.amount <= 0) return ElMessage.warning('请填写收款金额并选择收款凭证')
receiptBusy.value = true
try {
const voucher = await uploadFinanceVoucher(current.customerId, receiptFile.value)
await createFinanceReceipt({
customerId: current.customerId,
receivedDate: receiptForm.value.receivedDate,
amount: receiptForm.value.amount,
paymentMethod: receiptForm.value.paymentMethod,
remark: receiptForm.value.remark || undefined,
allocations: [{ billId: current.reconciliationStatementId, amount: receiptForm.value.amount }],
}, [voucher.fileId])
receiptOpen.value = false
ElMessage.success('收款已登记')
await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '收款登记失败') }
finally { receiptBusy.value = false }
}
async function submitPayment() {
if (!detail.value || !paymentFile.value || paymentForm.value.amount <= 0) return ElMessage.warning('请填写付款金额并选择付款凭证')
paymentBusy.value = true
try {
const voucher = await uploadFinanceVoucher(detail.value.customerId, paymentFile.value)
await createFinanceSettlementPayment(detail.value.id, { ...paymentForm.value, currency: 'CNY', voucherFileIds: [voucher.fileId], expectedVersion: detail.value.version })
paymentOpen.value = false
ElMessage.success('付款已登记')
await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '付款登记失败') }
finally { paymentBusy.value = false }
}
function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' }
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
onMounted(() => void loadCustomers())
</script>
<template>
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<ListPageLayout v-else :aria-label="title">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" preference-key="finance-settlement-query" @search="load" /></template>
<template v-if="customerError || error" #state><LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" preference-key="finance-settlement-query" @search="load" />
</template>
<template v-if="customerError || error" #state>
<LoadErrorState :title="`${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="`${title}列表`" row-key="id" empty-text="请选择客户查询结算单">
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceReceivableSettlement | FinancePayableSettlement)"><Eye :size="15" />详情</el-button></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button></template>
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button><el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link @click="openPayment(row)"><CreditCard :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="客户">{{ 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>
<AppDrawer v-model="detailOpen" :title="`${title}详情`" size="min(760px, 94vw)">
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="6" animated />
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收付">{{ detail.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收付">{{ detail.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item></el-descriptions></template>
</AppDrawer>
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
<el-form label-position="top">
<el-form-item v-if="props.kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.partnerName} · ${item.partnerCode}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
</el-form>
</AppDialog>
<AppDialog v-model="paymentOpen" title="登记付款" width="min(560px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
<el-form label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="凭证号"><el-input v-model="paymentForm.referenceNo" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="receiptOpen" title="登记收款" width="min(560px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<el-form label-position="top"><el-form-item label="到账日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>.finance-settlement-page{min-width:0}@media(max-width:720px){:deep(.el-drawer__footer .el-button){min-height:44px}}</style>
<style scoped>
.finance-settlement-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } }
</style>
@@ -0,0 +1,143 @@
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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import {
actionReconciliationStatement,
createFinanceReceipt,
getFinanceSettlement,
getReconciliationStatement,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
uploadFinanceVoucher,
} from '../api/finance'
import type { FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/finance', () => ({
actionReconciliationStatement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), createReconciliationStatement: vi.fn(),
getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceFeePool: vi.fn(),
listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), listFinanceStatements: vi.fn(),
uploadFinanceVoucher: 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),
}
}
function piniaWithPermissions(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
return pinia
}
const statement: FinanceStatementReadModel = {
id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31',
dueDate: '2026-09-30', totalAmount: 300, receivedAmount: 0, outstandingAmount: 300,
currency: 'CNY', status: 'ISSUED', version: 3,
}
const reconciliation: FinanceReconciliationDetail = {
...statement, paidAmount: 0, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false,
remark: '', lines: [
{ statementLineId: '601', sourceFeeId: '41', sourceFeeVersion: 1, orderId: null, inboundId: '91', feeItemName: '运费', amount: 100 },
{ statementLineId: '602', sourceFeeId: '42', sourceFeeVersion: 1, orderId: null, inboundId: '92', feeItemName: '操作费', amount: 200 },
], paymentHistory: [], revisions: [],
}
const receivable: FinanceSettlementDetail = {
id: '701', settlementNo: 'AR-701', customerId: '10001', reconciliationStatementId: '501', direction: 'RECEIVABLE',
amount: 300, paidAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 0,
lines: [], transactions: [], revisions: [],
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('finance G3/G4 page 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(actionReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue(receivable)
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
vi.mocked(uploadFinanceVoucher).mockReset().mockResolvedValue({ fileId: '9001', customerId: '10001', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 100 })
vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 })
})
it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => {
const pinia = piniaWithPermissions(['platform:finance:bill:view', 'platform:finance:reconciliation:adjust'])
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
openAdjustment(row: FinanceStatementReadModel): Promise<void>
submitAdjustment(): Promise<void>
adjustReason: string
adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }>
}
await vm.openAdjustment(statement)
vm.adjustReason = '账期复核调整'
vm.adjustLines[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正'
vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正'
await vm.submitAdjustment()
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', {
action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3,
adjustmentLines: [
{ statementLineId: '601', adjustedAmount: 90, reason: '运费更正' },
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
],
})
})
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:view', 'platform:finance:receipt:create'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
openReceipt(row: Record<string, unknown>): Promise<void>
submitReceipt(): Promise<void>
receiptFile: File | null
receiptForm: { receivedDate: string; amount: number; paymentMethod: 'BANK_TRANSFER'; remark: string }
}
await vm.openReceipt({ id: '701' })
vm.receiptFile = new File(['receipt'], 'receipt.png', { type: 'image/png' })
vm.receiptForm.receivedDate = '2026-08-11'; vm.receiptForm.amount = 300; vm.receiptForm.remark = '全额到账'
await vm.submitReceipt()
expect(uploadFinanceVoucher).toHaveBeenCalledWith('10001', vm.receiptFile)
expect(createFinanceReceipt).toHaveBeenCalledWith({
customerId: '10001', receivedDate: '2026-08-11', amount: 300, paymentMethod: 'BANK_TRANSFER', remark: '全额到账',
allocations: [{ billId: '501', amount: 300 }],
}, ['9001'])
})
})
@@ -0,0 +1,127 @@
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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { ApiResponseError } from '@/shared/api/http'
import {
actionReconciliationStatement,
createFinanceSettlementPayment,
createPayableSettlement,
createReceivableSettlement,
getFinanceSettlement,
getReconciliationStatement,
listFinanceFeePool,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
listFinanceStatements,
uploadFinanceVoucher,
} from '../api/finance'
import type { FinancePayableSettlement, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('../api/finance', () => ({
actionReconciliationStatement: vi.fn(), createFinanceSettlementPayment: vi.fn(), createPayableSettlement: vi.fn(),
createReceivableSettlement: vi.fn(), getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(),
listFinanceFeePool: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
listFinanceStatements: vi.fn(), uploadFinanceVoucher: vi.fn(), createReconciliationStatement: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): 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 statement: FinanceStatementReadModel = { id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '2026-09-30', totalAmount: 300, receivedAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 3 }
const statementDetail: FinanceReconciliationDetail = {
...statement, paidAmount: 0, remark: '', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, paymentHistory: [], revisions: [],
lines: [
{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 1, orderId: '11', inboundId: '21', feeItemName: '运费', amount: 100 },
{ statementLineId: '602', sourceFeeId: '82', sourceFeeVersion: 1, orderId: '12', inboundId: '22', feeItemName: '操作费', amount: 200 },
],
}
const payable: FinancePayableSettlement = { id: '701', customerId: '10001', settlementNo: 'AP-701', costType: 'SINGLE_ORDER', orderId: '11', inboundId: null, containerId: null, supplierName: '供应商A', feeItemName: '拖车费', payableAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', createdAt: '2026-08-11', version: 4 }
const settlementDetail = { id: '701', settlementNo: 'AP-701', customerId: '10001', supplierId: '301', direction: 'PAYABLE' as const, amount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY' as const, status: 'ISSUED', version: 4, lines: [], transactions: [], revisions: [] }
async function mountWithAccess(component: typeof FinanceCustomerStatementsView | typeof FinanceFormalSettlementPage, permissions: string[], props: Record<string, unknown> = {}) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('Finance G3/G4 views', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.stubGlobal('sessionStorage', memoryStorage())
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(listPlatformPartners).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', dataScopeId: '0', partnerCode: 'SUP-301', partnerName: '供应商A', partnerType: 'SUPPLIER', types: ['SUPPLIER'], contactName: '', contactPhone: '', contactEmail: null, countryCode: 'CN', province: null, city: '', district: null, addressLine: '', postalCode: null, status: 'ACTIVE', remark: null } as PlatformPartnerResource], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement])
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'PAYABLE:81', feeSourceId: '81', direction: 'PAYABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 1, orderId: '11', inboundId: '21', containerId: null, feeItemName: '拖车费', amount: 200, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }])
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([payable])
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
vi.mocked(createPayableSettlement).mockReset().mockResolvedValue(settlementDetail)
vi.mocked(createReceivableSettlement).mockReset().mockResolvedValue({ ...settlementDetail, id: '801', settlementNo: 'AR-801', direction: 'RECEIVABLE', supplierId: undefined })
vi.mocked(uploadFinanceVoucher).mockReset().mockResolvedValue({ fileId: '9001', customerId: '10001', fileName: 'voucher.png', contentType: 'image/png', sizeBytes: 1 })
vi.mocked(createFinanceSettlementPayment).mockReset().mockResolvedValue({ ...settlementDetail, paidAmount: 100, outstandingAmount: 100, status: 'PARTIALLY_PAID', version: 5 })
})
it('hides G3/G4 commands when only legacy bill view permission is present', async () => {
const statements = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:bill:view'])
const settlements = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:bill:view'], { kind: 'payable' })
expect(statements.text()).not.toContain('生成对账单')
expect(settlements.text()).toContain('无权访问结算单应付')
expect(createPayableSettlement).not.toHaveBeenCalled()
expect(actionReconciliationStatement).not.toHaveBeenCalled()
})
it('submits every selected adjustment line with the detail version', async () => {
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:bill:view', 'platform:finance:reconciliation:adjust'])
const vm = wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void>; submitAdjustment(): Promise<void>; adjustReason: string; adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> }
await vm.openAdjustment(statement)
vm.adjustReason = '月末核对更正'
vm.adjustLines.forEach((line, index) => { line.selected = true; line.adjustedAmount = index === 0 ? 90 : 180; line.reason = `明细${index + 1}更正` })
await vm.submitAdjustment()
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { action: 'ADJUST', reason: '月末核对更正', expectedVersion: 3, adjustmentLines: [{ statementLineId: '601', adjustedAmount: 90, reason: '明细1更正' }, { statementLineId: '602', adjustedAmount: 180, reason: '明细2更正' }] })
})
it('creates a payable settlement and registers a voucher-backed payment', async () => {
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:view', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' })
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } }
await vm.load({ customerId: '10001' }); await vm.openCreate()
expect(listPlatformPartners).toHaveBeenCalledWith({ page: 1, size: 200, type: 'SUPPLIER', status: 'ACTIVE' })
expect(document.body.textContent).toContain('供应商A · SUP-301')
Object.assign(vm.createForm, { supplierId: '301', dueDate: '2026-09-30', feeSourceIds: ['81'] })
await vm.submitCreate()
expect(createPayableSettlement).toHaveBeenCalledWith({ customerId: '10001', supplierId: '301', feeSourceIds: ['81'], dueDate: '2026-09-30', currency: 'CNY' })
vm.openPayment(payable); vm.paymentFile = new File(['x'], 'voucher.png', { type: 'image/png' }); vm.paymentForm.amount = 100
await vm.submitPayment()
expect(createFinanceSettlementPayment).toHaveBeenCalledWith('701', expect.objectContaining({ amount: 100, voucherFileIds: ['9001'], expectedVersion: 4 }))
})
it('renders request and trace ids when G3 detail loading fails', async () => {
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('对账单不可用', 'FIN_404', 404, undefined, 'req-g3-error', 'trace-g3-error'))
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:bill:view'])
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
await vm.openDetail(statement); await flushPromises()
expect(document.body.textContent).toContain('req-g3-error')
expect(document.body.textContent).toContain('trace-g3-error')
})
})
@@ -9,7 +9,7 @@ const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/Fi
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('customerName(detail.customerId)')
expect(settlement).toContain('activeCustomers.value.map')
expect(settlement).not.toContain('{{ selected.customerId }}')
})
@@ -30,10 +30,15 @@ describe('finance presentation governance', () => {
expect(manual).not.toContain('凭证 fileId:')
})
it('hides unavailable generation controls instead of rendering disabled buttons', () => {
it('exposes frozen G3 controls without unavailable placeholders', () => {
expect(settlement).not.toContain('生成{{ title }}')
expect(settlement).not.toContain('接口尚未提供,暂不可操作')
expect(statements).not.toContain('生成对账单')
expect(statements).toContain('生成对账单')
expect(statements).toContain('createReconciliationStatement')
expect(statements).toContain('actionReconciliationStatement')
expect(statements).toContain('<template #toolbar-actions>')
expect(statements).toContain('aria-label="更多对账单操作"')
expect(statements).toContain('adjustmentLines: selected.map')
expect(statements).not.toContain('disabled title="接口尚未提供')
})
})
+52
View File
@@ -164,6 +164,7 @@ export interface FinanceSettlementDetail {
settlementNo: string
customerId: string
supplierId?: string
reconciliationStatementId?: string
direction: 'RECEIVABLE' | 'PAYABLE'
amount: number
paidAmount: number
@@ -176,6 +177,56 @@ export interface FinanceSettlementDetail {
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; occurredAt: string }>
}
export interface FinanceReconciliationDetail extends FinanceBillDetail {
periodStart: string
periodEnd: string
creditDays: number
settled: boolean
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; occurredAt: string; remark?: string }>
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; occurredAt: string }>
}
export interface CreateReconciliationPayload {
customerId: string
currency: 'CNY'
generationMode: 'BY_CUTOFF' | 'SELECTED'
feeSourceIds?: string[]
periodStart: string
periodEnd: string
remark?: string
}
export interface ReconciliationActionPayload {
action: 'SUBMIT' | 'APPROVE' | 'REJECT' | 'ISSUE' | 'ADJUST' | 'VOID' | 'REOPEN'
reason?: string
expectedVersion: number
adjustmentLines?: Array<{ statementLineId: string; adjustedAmount: number; reason: string }>
}
export interface CreateReceivableSettlementPayload {
reconciliationStatementId: string
currency: 'CNY'
}
export interface CreatePayableSettlementPayload {
customerId: string
supplierId: string
feeSourceIds: string[]
dueDate: string
currency: 'CNY'
}
export interface CreateSettlementPaymentPayload {
transactionDate: string
amount: number
currency: 'CNY'
method: FinancePaymentMethod
referenceNo?: string
voucherFileIds: string[]
expectedVersion: number
remark?: string
}
export interface FinanceBillResource {
id: string
billNo: string
@@ -192,6 +243,7 @@ export interface FinanceBillResource {
}
export interface FinanceBillLine {
statementLineId?: string
sourceFeeId: string
sourceFeeVersion: number
orderId: string | null
@@ -151,6 +151,15 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.text()).toContain('预览图片')
})
it('does not expose unknown measurement source codes', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ sourceType: 'LEGACY_DEVICE' }))
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('未知来源')
expect(wrapper.text()).not.toContain('LEGACY_DEVICE')
})
it('follows the prototype grouping, labels, and editable field order', async () => {
const wrapper = await mountPanel()
const text = wrapper.text()
@@ -92,7 +92,7 @@ const snapshot = computed(() => JSON.stringify(form))
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || detail.value?.sourceType || '尚未测量'))
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未测量')))
const sourceTagType = computed(() => detail.value?.sourceType === 'MANUAL' ? 'info' : 'primary')
const { captureBaseline } = useUnsavedWorkRegistration({
@@ -56,7 +56,7 @@ function actionLabel(action: string) {
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
} as Record<string, string>)[action] || action
} as Record<string, string>)[action] || '状态更新'
}
function formatDateTime(value?: string | null) {
@@ -71,4 +71,9 @@ describe('LoadingListsView', () => {
expect(source).not.toContain('<StatusBadge :status="row.status" />')
expect(detailSource).toContain('<StatusBadge :status="loadingListStatusLabel(summary.status)" />')
})
it('does not expose unknown history action codes', () => {
expect(detailSource).toContain("[action] || '状态更新'")
expect(detailSource).not.toContain('[action] || action')
})
})
@@ -22,6 +22,12 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
})
it('keeps historical partner names visible when an existing partner is no longer selectable', () => {
expect(source).toContain("form.data.shippingCompanyName || '当前船公司'")
expect(source).toContain("form.data.bookingAgentName || '当前订舱代理'")
expect(source).toContain("from '@/modules/master-data'")
})
it('renders the translated status once in the detail heading', () => {
expect(source).toContain('<StatusBadge :status="statusLabel(detail.status)" />')
expect(source).not.toContain('<StatusBadge :status="detail.status" />')
@@ -13,7 +13,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data/counterparties'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import {
createOceanBooking,
getOceanBooking,
@@ -252,8 +252,8 @@ onMounted(() => { void load(); void loadOptions() })
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="form-grid">
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '当前船公司'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '当前订舱代理'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="船名"><el-input v-model="form.data.vesselName" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" placeholder="请输入航次" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
@@ -33,6 +33,12 @@ describe('PortSchedulesView', () => {
expect(source).not.toContain('label="订舱 ID"')
})
it('hides inactive customers while preserving the customer bound to an existing schedule', () => {
expect(source).toContain("customer.status === 'ACTIVE' || String(customer.id) === String(form.customerId)")
expect(source).toContain('v-for="customer in selectableCustomers"')
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'')
})
it('protects candidate requests and exposes loading, empty, error and retry states', () => {
expect(source).toContain('customerRequestSerial')
expect(source).toContain('bookingRequestSerial')
@@ -34,6 +34,9 @@ const dialogOpen = ref(false); const dialogMode = ref<DialogMode>('create'); con
const rules: FormRules = { customerId: [{ required: true, message: '请选择客户', trigger: 'change' }], oceanBookingId: [{ required: true, message: '请选择订舱记录', trigger: 'change' }], sourceName: [{ required: true, message: '请输入信息来源', trigger: 'blur' }], sourceRef: [{ required: true, message: '请输入来源链接或参考号', trigger: 'blur' }], vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }], voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }], departurePortCode: [{ required: true, message: '请输入起运港代码', trigger: 'blur' }], destinationPortCode: [{ required: true, message: '请输入目的港代码', trigger: 'blur' }], observedAt: [{ required: true, message: '请选择查询时间', trigger: 'change' }], expiresAt: [{ required: true, message: '请选择过期时间', trigger: 'change' }] }
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref<ApiErrorDetails | null>(null); let customerRequestSerial = 0
const bookings = ref<OceanBookingResource[]>([]); const bookingsLoading = ref(false); const bookingsError = ref<ApiErrorDetails | null>(null); let bookingRequestSerial = 0
const selectableCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === String(form.customerId)
)))
const canCreate = computed(() => access.can('platform:firstleg:port-schedule:create'))
const dialogTitle = computed(() => dialogMode.value === 'create' ? '新增船期' : dialogMode.value === 'edit' ? '编辑船期' : '查看船期')
const dialogReadonly = computed(() => dialogMode.value === 'view')
@@ -60,7 +63,7 @@ watch([page, pageSize], () => void load()); onMounted(load)
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="dialogOpen" :title="dialogTitle" width="820px" :confirm-loading="saving" :show-confirm="!dialogReadonly" :cancel-text="dialogReadonly ? '关闭' : '取消'" @confirm="save"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><div v-if="customersError" class="selector-error"><span>{{ customersError.message }}</span><el-button link type="primary" @click="loadCustomers">重新加载客户</el-button></div><div v-if="bookingsError" class="selector-error"><span>{{ bookingsError.message }}</span><el-button link type="primary" @click="loadBookings()">重新加载订舱</el-button></div><el-form ref="formRef" :model="form" :rules="rules" :disabled="dialogReadonly" label-position="top" class="schedule-form"><el-row :gutter="16">
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="form.customerId" filterable placeholder="请选择客户" :loading="customersLoading" no-data-text="暂无可用客户" style="width:100%" @update:model-value="handleCustomerChange(String($event || ''))"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customerLabel(customer)" :value="String(customer.id)" :disabled="customer.status !== 'ACTIVE' && String(customer.id) !== String(form.customerId)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="form.customerId" filterable placeholder="请选择客户" :loading="customersLoading" no-data-text="暂无可用客户" style="width:100%" @update:model-value="handleCustomerChange(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customerLabel(customer)" :value="String(customer.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="订舱记录" prop="oceanBookingId"><el-select v-model="form.oceanBookingId" filterable placeholder="请先选择客户,再选择订舱记录" :loading="bookingsLoading" :disabled="!form.customerId" no-data-text="该客户暂无订舱记录" style="width:100%"><el-option v-for="booking in bookings" :key="booking.id" :label="bookingLabel(booking)" :value="booking.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="信息来源" prop="sourceName"><el-input v-model="form.sourceName" placeholder="如美森官网" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="来源参考" prop="sourceRef"><el-input v-model="form.sourceRef" placeholder="查询链接或参考号" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="船名" prop="vesselName"><el-input v-model="form.vesselName" placeholder="请输入船名" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="航次" prop="voyageNo"><el-input v-model="form.voyageNo" placeholder="请输入航次" /></el-form-item></el-col>
@@ -25,6 +25,14 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain('重新加载选项')
})
it('hides inactive options while preserving resources already bound to an appointment', () => {
expect(source).toContain("item.status === 'ACTIVE' || String(item.id) === String(draft.customerId)")
expect(source).toContain("item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)")
expect(source).toContain('v-for="item in selectableCustomers"')
expect(source).toContain('v-for="item in selectableWarehouses"')
expect(source).not.toContain(':disabled="item.status !== \'ACTIVE\'')
})
it('gives editable fields clear placeholders', () => {
expect(source).toContain('placeholder="请输入司机姓名"')
expect(source).toContain('placeholder="请选择预计装箱时间"')
@@ -42,6 +42,15 @@ const bookings = ref<OceanBookingResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const selectableCustomers = computed(() => customers.value.filter((item) => (
item.status === 'ACTIVE' || String(item.id) === String(draft.customerId)
)))
const selectableWarehouses = computed(() => warehouses.value.filter((item) => (
item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)
)))
const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE' || String(item.id) === String(draft.carrierId)
)))
function selectCustomer(customerId: string) { draft.customerId = customerId || null; draft.customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || null }
function selectCarrier(carrierId: string) { draft.carrierId = carrierId || null; draft.carrierName = carriers.value.find((item) => String(item.id) === carrierId)?.partnerName || null }
function selectContainer(containerId: string) { const item = containers.value.find((entry) => entry.id === containerId); draft.containerBookingId = containerId || null; if (item) { draft.loadingPlanId = item.planId; draft.warehouseId = item.warehouseId } }
@@ -79,11 +88,11 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
<el-col :xs="24"><el-divider content-position="left">关联资源</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="请选择订舱单" :loading="optionsLoading" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || `订舱 ${item.id}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" :disabled="item.status !== 'ACTIVE'" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" :disabled="item.status !== 'ACTIVE'" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '当前客户'" :value="String(draft.customerId)" /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable placeholder="请选择承运商" :loading="optionsLoading" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-for="item in carriers" :key="String(item.id)" :label="item.partnerName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable placeholder="请选择承运商" :loading="optionsLoading" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前承运商'" :value="String(draft.carrierId)" /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.partnerName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" placeholder="请输入车队联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" placeholder="请输入司机姓名" /></el-form-item></el-col>
@@ -69,11 +69,11 @@ describe('unified orders API contract', () => {
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', customerRemark: '客户备注', latestInternalNote: '最新内部备注', measurement: null, preload: null, loading: null, delivery: null, transport: null }],
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
page: 1, size: 20, total: 1, pages: 1,
} })
await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', customerRemark: '客户备注', latestInternalNote: '最新内部备注' }],
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
})
})
@@ -63,12 +63,21 @@ export interface UnifiedOrderTransportSummary {
portArrivalAt: string | null
}
export interface UnifiedOrderRelationSummary {
role: 'PARENT' | 'CHILD'
rootOrderId: number | string | null
parentOrderId: number | string | null
splitSequence: number | null
childCount: number
}
export interface UnifiedOrderResource {
orderId: number | string
tenantId: number | string
orderNo: string | null
mainStatus: UnifiedOrderMainStatus
nextAction: string | null
relation: UnifiedOrderRelationSummary | null
internalOrderStatus: string | null
customerId: number | string | null
customerName: string | null
@@ -319,6 +328,13 @@ function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
destinationWarehouseId: id(raw.destinationWarehouseId),
inboundId: id(raw.inboundId),
inboundVersion: nullableInteger(raw.inboundVersion),
relation: raw.relation ? {
...raw.relation,
rootOrderId: id(raw.relation.rootOrderId),
parentOrderId: id(raw.relation.parentOrderId),
splitSequence: nullableInteger(raw.relation.splitSequence),
childCount: integer(raw.relation.childCount, 0),
} : null,
totalPackageCount: nullableInteger(raw.totalPackageCount),
totalPalletCount: nullableInteger(raw.totalPalletCount),
recommendedPalletType: raw.recommendedPalletType ?? null,
@@ -162,9 +162,13 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain("value.replaceAll('_', ' ')")
})
it('exposes the single-remainder fulfillment aggregate in row expansion and detail', () => {
expect(source).toContain("{ key: 'fulfillment', type: 'expand', width: 52, fixed: 'left'")
expect(source).toContain('<template #fulfillment="{ row }">')
it('shows parent-child markers only for related rows and keeps the aggregate in detail', () => {
expect(source).not.toContain("type: 'expand'")
expect(source).toContain('v-if="(row as UnifiedOrderResource).relation"')
expect(source).toContain('父单 · ${row.relation.childCount} 个子单')
expect(source).toContain('子单 · 第 ${row.relation.splitSequence} 次')
expect(source).toContain('title="父子订单关联"')
expect(source).toContain('@click="openOrderRelation(row as UnifiedOrderResource)"')
expect(source).toContain("{ key: 'fulfillment', label: '拆分履约', icon: ListChecks }")
expect(source).toContain("activeDetailTab === 'fulfillment'")
expect(source).toContain('根单、已完成分片与当前余量')
@@ -184,7 +188,6 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('aria-label="订单详情模块"')
expect(source).toContain("{ key: 'basic', label: '基本信息'")
expect(source).toContain("{ key: 'measurement', label: '测量数据'")
expect(source).not.toContain('<AppDrawer')
expect(source).not.toContain('detailOpen')
expect(source).toContain('watch(detailRouteKey,')
expect(source).toContain('requestKey !== detailRouteKey.value')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -23,6 +23,7 @@ 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 DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -97,6 +98,8 @@ const noteOpen = ref(false)
const noteSaving = ref(false)
const noteContent = ref('')
const noteOrder = ref<UnifiedOrderResource | null>(null)
const relationOpen = ref(false)
const relationOrder = ref<UnifiedOrderResource | null>(null)
const feeDetails = ref<UnifiedOrderFeeDetailResource[]>([])
const feeLoading = ref(false)
const feeLoadedKey = ref('')
@@ -197,8 +200,7 @@ const queryFields = computed(() => showAdvancedFilters.value
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'fulfillment', type: 'expand', width: 52, fixed: 'left', slotName: 'fulfillment', hideable: false, movable: false },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' },
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'measurementReviewStatus' },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到达时间', minWidth: 150, slotName: 'field' },
@@ -303,6 +305,19 @@ function customerName(row: UnifiedOrderResource) {
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || `客户 ${row.tenantId}`
}
function orderRelationLabel(row: UnifiedOrderResource) {
if (row.relation?.role === 'PARENT') return `父单 · ${row.relation.childCount} 个子单`
if (row.relation?.role === 'CHILD' && row.relation.splitSequence !== null) {
return `子单 · 第 ${row.relation.splitSequence} 次`
}
return '子单'
}
function openOrderRelation(row: UnifiedOrderResource) {
relationOrder.value = row
relationOpen.value = true
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
@@ -1074,12 +1089,18 @@ onMounted(() => {
</el-dropdown>
</div>
</template>
<template #fulfillment="{ row }">
<OrderFulfillmentTreePanel
:tenant-id="String((row as UnifiedOrderResource).tenantId)"
:order-id="String((row as UnifiedOrderResource).orderId)"
compact
/>
<template #inboundCode="{ row }">
<div class="inbound-relation-cell">
<span>{{ (row as UnifiedOrderResource).inboundCode || '—' }}</span>
<el-button
v-if="(row as UnifiedOrderResource).relation"
class="order-relation-link"
link
type="primary"
:aria-label="`查看${orderRelationLabel(row as UnifiedOrderResource)}关联`"
@click="openOrderRelation(row as UnifiedOrderResource)"
><GitBranch :size="14" />{{ orderRelationLabel(row as UnifiedOrderResource) }}</el-button>
</div>
</template>
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
@@ -1100,6 +1121,14 @@ onMounted(() => {
</ListPageLayout>
</template>
<AppDrawer v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
<OrderFulfillmentTreePanel
v-if="relationOrder"
:tenant-id="String(relationOrder.tenantId)"
:order-id="String(relationOrder.orderId)"
/>
</AppDrawer>
<AppDialog v-model="noteOpen" title="添加内部备注" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
</AppDialog>
@@ -1130,6 +1159,10 @@ onMounted(() => {
.table-card { overflow: hidden; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
.inbound-relation-cell { display: grid; min-width: 0; gap: 5px; }
.inbound-relation-cell > span { overflow-wrap: anywhere; }
.order-relation-link { width: fit-content; max-width: 100%; height: auto; justify-content: flex-start; padding: 0; font-size: 12px; }
.order-relation-link :deep(span) { min-width: 0; white-space: normal; text-align: left; }
.primary-cell { display: grid; min-width: 0; gap: 4px; }
.primary-cell strong { overflow-wrap: anywhere; }
.primary-cell span { color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }