From 6622815d6c0703f936e46028da111ec24cf644a4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 18:43:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CustomerOverseasStorageFeePanel.vue | 21 +-- .../customers/overseasStorageFeeTiers.test.ts | 33 ++++ .../customers/overseasStorageFeeTiers.ts | 61 +++++++ .../pages/OverseasStorageFeesView.vue | 13 +- .../customs/pages/CustomsCasesView.test.ts | 9 ++ .../customs/pages/CustomsCasesView.vue | 9 +- src/modules/customs/status.test.ts | 26 +++ src/modules/customs/status.ts | 35 ++++ src/modules/finance/api/finance.test.ts | 20 ++- src/modules/finance/api/finance.ts | 31 ++-- src/modules/finance/financeDelivery.test.ts | 12 ++ .../pages/FinanceCustomerAccountsView.vue | 151 ++++++++---------- .../finance/pages/FinanceCustomerArapView.vue | 11 +- .../pages/FinanceCustomerStatementsView.vue | 28 +++- .../pages/FinanceFormalSettlementPage.vue | 61 +++++++ .../pages/FinanceSettlementPayablesView.vue | 4 +- .../FinanceSettlementReceivablesView.vue | 4 +- src/modules/finance/types.ts | 68 +++----- src/modules/ocean-bookings/index.ts | 1 + .../pages/OceanBookingDetailView.test.ts | 5 + .../pages/OceanBookingDetailView.vue | 9 +- .../pages/OceanBookingsView.test.ts | 2 +- .../pages/OceanBookingsView.vue | 9 +- src/modules/ocean-bookings/status.test.ts | 21 +++ src/modules/ocean-bookings/status.ts | 21 +++ .../pages/OutboundDispatchesView.test.ts | 12 +- .../pages/OutboundDispatchesView.vue | 7 +- .../outbound-dispatch/statusLabels.test.ts | 31 ++++ src/modules/outbound-dispatch/statusLabels.ts | 111 +++++++++++++ .../pages/TruckAppointmentDetailView.vue | 4 +- .../trucking/pages/TruckAppointmentsView.vue | 6 +- src/modules/trucking/status.test.ts | 11 ++ src/modules/trucking/types.ts | 24 +++ .../components/SmartLoadingWorkbench.vue | 7 +- .../UnifiedOrderWorkflowPanel.test.ts | 36 +++++ .../components/UnifiedOrderWorkflowPanel.vue | 22 ++- src/shared/components/StatusBadge.vue | 8 +- 37 files changed, 739 insertions(+), 205 deletions(-) create mode 100644 src/modules/customers/overseasStorageFeeTiers.test.ts create mode 100644 src/modules/customers/overseasStorageFeeTiers.ts create mode 100644 src/modules/customs/status.test.ts create mode 100644 src/modules/customs/status.ts create mode 100644 src/modules/finance/pages/FinanceFormalSettlementPage.vue create mode 100644 src/modules/ocean-bookings/status.test.ts create mode 100644 src/modules/ocean-bookings/status.ts create mode 100644 src/modules/outbound-dispatch/statusLabels.test.ts create mode 100644 src/modules/outbound-dispatch/statusLabels.ts create mode 100644 src/modules/trucking/status.test.ts diff --git a/src/modules/customers/CustomerOverseasStorageFeePanel.vue b/src/modules/customers/CustomerOverseasStorageFeePanel.vue index 483d8e49..3235a78c 100644 --- a/src/modules/customers/CustomerOverseasStorageFeePanel.vue +++ b/src/modules/customers/CustomerOverseasStorageFeePanel.vue @@ -1,13 +1,14 @@ - diff --git a/src/modules/customs/status.test.ts b/src/modules/customs/status.test.ts new file mode 100644 index 00000000..d7abfa0f --- /dev/null +++ b/src/modules/customs/status.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { customsCaseStatusOptions, customsHistoryActionLabel, customsStatusLabel } from './status' + +describe('customs status labels', () => { + it('matches the backend customs case state machine', () => { + expect(customsCaseStatusOptions).toEqual([ + { value: 'DOCUMENTS_PENDING', label: '待补资料' }, + { value: 'REVIEWING', label: '审核中' }, + { value: 'SUPPLEMENT_REQUIRED', label: '需补件' }, + { value: 'READY_TO_SUBMIT', label: '待提交代理' }, + { value: 'RECEIPT_PENDING', label: '待回执' }, + { value: 'RECEIPT_AVAILABLE', label: '已有回执' }, + { value: 'CLEARANCE_COMPLETED', label: '已放行' }, + { value: 'CANCELLED', label: '已取消' }, + ]) + }) + + it('localizes status transitions and does not expose backend codes', () => { + expect(customsStatusLabel('READY_TO_SUBMIT')).toBe('待提交代理') + expect(customsStatusLabel(null)).toBe('—') + expect(customsStatusLabel('FUTURE_STATUS')).toBe('未知状态') + expect(customsHistoryActionLabel('REVIEW_DOCUMENT_RETURN')).toBe('退回补件') + expect(customsHistoryActionLabel('SUBMIT_AGENT_BATCH')).toBe('提交报关代理') + expect(customsHistoryActionLabel('FUTURE_ACTION')).toBe('状态更新') + }) +}) diff --git a/src/modules/customs/status.ts b/src/modules/customs/status.ts new file mode 100644 index 00000000..ac873904 --- /dev/null +++ b/src/modules/customs/status.ts @@ -0,0 +1,35 @@ +export const customsCaseStatusOptions = [ + { value: 'DOCUMENTS_PENDING', label: '待补资料' }, + { value: 'REVIEWING', label: '审核中' }, + { value: 'SUPPLEMENT_REQUIRED', label: '需补件' }, + { value: 'READY_TO_SUBMIT', label: '待提交代理' }, + { value: 'RECEIPT_PENDING', label: '待回执' }, + { value: 'RECEIPT_AVAILABLE', label: '已有回执' }, + { value: 'CLEARANCE_COMPLETED', label: '已放行' }, + { value: 'CANCELLED', label: '已取消' }, +] as const + +const statusLabels = Object.fromEntries(customsCaseStatusOptions.map((option) => [option.value, option.label])) as Record + +const historyActionLabels: Record = { + CREATE: '创建报关协同', + CREATE_CASE: '创建报关协同', + UPLOAD_DOCUMENT: '上传报关资料', + REPLACE_DOCUMENT: '替换报关资料', + REVIEW_DOCUMENT_APPROVE: '资料审核通过', + REVIEW_DOCUMENT_RETURN: '退回补件', + SUBMIT_AGENT_BATCH: '提交报关代理', + UPLOAD_RECEIPT: '上传报关回执', + CANCEL: '取消报关协同', + CANCEL_CASE: '取消报关协同', +} + +export function customsStatusLabel(status: string | null | undefined) { + if (!status) return '—' + return statusLabels[status] || '未知状态' +} + +export function customsHistoryActionLabel(action: string | null | undefined) { + if (!action) return '状态更新' + return historyActionLabels[action] || '状态更新' +} diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index ba88b503..81fca95c 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, issueFinanceBill, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance' +import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance' describe('finance api', () => { beforeEach(() => { @@ -18,6 +18,24 @@ describe('finance api', () => { expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2 }) }) + it('uses the formal G1 account list path and fixed page DTO', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } }) + + const result = await listFinanceAccounts({ page: 2, size: 20, keyword: '客户A', status: 'ACTIVE' }) + + expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts', { params: { page: 2, pageSize: 20, keyword: '客户A', status: 'ACTIVE' } }) + expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], total: 21, pages: 2 }) + }) + + it('uses the formal G1 account ledger path and fixed page DTO', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' }], page: { page: 1, pageSize: 50, total: 1 } }) + + const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' }) + + expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts/10001/ledger', { params: { page: 1, pageSize: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' } }) + expect(result.records[0]).toEqual({ ledgerId: '81', occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: '91', documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' }) + }) + it('uses fixed read-model paths for all five finance pages', async () => { const get = vi.spyOn(http, 'get') .mockResolvedValueOnce({ code: '200', message: 'ok', data: { customerId: 10001, totalReceivable: 300, totalReceived: 100, outstandingAmount: 200, entries: [] } }) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index e928104e..64156434 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -3,8 +3,8 @@ import type { FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, Fin const basePath = '/platform/finance/v1' export const financePhase1Capabilities = { - accountList: false, - accountLedger: false, + accountList: true, + accountLedger: true, feeSourceDetail: false, feeSourceReview: false, feeSourceSettlementInclusion: false, @@ -74,24 +74,27 @@ export async function listFinanceFeePool(customerId: string, direction?: 'RECEIV } export async function listFinanceAccounts(params: { page?: number; size?: number; keyword?: string; status?: string } = {}): Promise<{ records: FinanceAccountSummary[]; total: number; pages: number }> { - const response = await http.get, ApiEnvelope>(`${basePath}/accounts`, { params }) - const data = unwrapApiResponse(response) as RawResource - const records = (Array.isArray(data.records) ? data.records : Array.isArray(data.items) ? data.items : []).map((item) => ({ customerId: stringValue(item.customerId ?? item.customer_id), customerName: stringValue(item.customerName ?? item.customer_name), customerStatus: stringValue(item.customerStatus ?? item.status), currency: 'CNY' as const, totalReceivable: numberValue(item.totalReceivable ?? item.total_receivable), receivedAmount: numberValue(item.receivedAmount ?? item.totalReceived ?? item.received_amount), outstandingAmount: numberValue(item.outstandingAmount ?? item.outstanding_amount), creditDays: numberValue(item.creditDays ?? item.credit_days ?? 30) })) - return { records, total: numberValue(data.total ?? records.length), pages: numberValue(data.pages ?? 1) || 1 } + const query = { page: params.page ?? 1, pageSize: params.size ?? 20, keyword: params.keyword || undefined, status: params.status || undefined } + const response = await http.get & { page?: RawResource }, ApiEnvelope & { page?: RawResource }>(`${basePath}/accounts`, { params: query }) + const data = unwrapApiResponse(response) + const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) })) + const total = numberValue(response.page?.total ?? records.length) + const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize + return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) } } export async function listFinanceAccountLedger(customerId: string, params: { page?: number; size?: number; businessType?: string; startDate?: string; endDate?: string } = {}): Promise<{ records: FinanceLedgerEntry[]; total: number; pages: number }> { - const response = await http.get, ApiEnvelope>(`${basePath}/accounts/${customerId}/ledger`, { params }) - const data = unwrapApiResponse(response) as RawResource - const records = (Array.isArray(data.records) ? data.records : Array.isArray(data.items) ? data.items : []).map((item) => ({ entryId: stringValue(item.entryId ?? item.id), businessType: stringValue(item.businessType), resourceId: stringValue(item.resourceId), resourceNo: stringValue(item.resourceNo), occurredAt: stringValue(item.occurredAt), debitAmount: numberValue(item.debitAmount), creditAmount: numberValue(item.creditAmount), balance: numberValue(item.balance), currency: 'CNY' as const, status: stringValue(item.status) })) - return { records, total: numberValue(data.total ?? records.length), pages: numberValue(data.pages ?? 1) || 1 } + const query = { page: params.page ?? 1, pageSize: params.size ?? 20, businessType: params.businessType || undefined, startDate: params.startDate || undefined, endDate: params.endDate || undefined } + const response = await http.get & { page?: RawResource }, ApiEnvelope & { page?: RawResource }>(`${basePath}/accounts/${customerId}/ledger`, { params: query }) + const data = unwrapApiResponse(response) + const records = data.map((item) => ({ ledgerId: stringValue(item.ledgerId), occurredAt: stringValue(item.occurredAt), businessType: stringValue(item.businessType), documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo), receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange), outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark) })) + const total = numberValue(response.page?.total ?? records.length) + const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize + return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) } } export async function getFinanceFeeSource(feeSourceId: string): Promise { - const response = await http.get, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}`) - const item = unwrapApiResponse(response) - const pool = (await Promise.resolve([item])).map((raw) => ({ poolItemId: stringValue(raw.poolItemId ?? raw.id), direction: stringValue(raw.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(raw.sourceType), sourceId: stringValue(raw.sourceId), sourceVersion: numberValue(raw.sourceVersion) || 1, orderId: raw.orderId == null ? null : stringValue(raw.orderId), inboundId: raw.inboundId == null ? null : stringValue(raw.inboundId), containerId: raw.containerId == null ? null : stringValue(raw.containerId), feeItemName: stringValue(raw.feeItemName), amount: numberValue(raw.amount), currency: 'CNY' as const, status: stringValue(raw.status), createdAt: stringValue(raw.createdAt) }))[0] - return { ...pool, reviewStatus: stringValue(item.reviewStatus), settlementIncluded: Boolean(item.settlementIncluded), sourceStatus: stringValue(item.sourceStatus), reviewReason: stringValue(item.reviewReason), settlementReason: stringValue(item.settlementReason), sourceHash: stringValue(item.sourceHash), operationLogs: Array.isArray(item.operationLogs) ? item.operationLogs as FinanceFeeSourceDetail['operationLogs'] : [], version: numberValue(item.version) } + throw new FinanceCapabilityBlockedError('getFinanceFeeSource') } export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }) { throw new FinanceCapabilityBlockedError('reviewFinanceFeeSource') } diff --git a/src/modules/finance/financeDelivery.test.ts b/src/modules/finance/financeDelivery.test.ts index 47662b72..ad4457b5 100644 --- a/src/modules/finance/financeDelivery.test.ts +++ b/src/modules/finance/financeDelivery.test.ts @@ -45,6 +45,18 @@ describe('finance five-page delivery contract', () => { expect(api).not.toContain('function listFinanceReceipts') expect(api).not.toContain('function listFinanceCosts') }) + + it('connects G1 customer accounts to the formal list and ledger operations', () => { + const page = source('src/modules/finance/pages/FinanceCustomerAccountsView.vue') + expect(api).toContain('`${basePath}/accounts`') + expect(api).toContain('`${basePath}/accounts/${customerId}/ledger`') + expect(page).toContain('listFinanceAccounts') + expect(page).toContain('listFinanceAccountLedger') + expect(page).not.toContain('getFinanceCustomerAccount') + expect(page).not.toContain('接口尚未提供') + expect(page.indexOf("key: 'actions'")).toBeLessThan(page.indexOf("key: 'customerName'")) + for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state) + }) }) function basePathMarker(endpoint: string) { diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index db21162d..aff23a0f 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref } from 'vue' import { Eye, Pencil, Save } from '@lucide/vue' import { ElMessage } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' -import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' @@ -13,128 +12,120 @@ import FormSection from '@/shared/components/FormSection.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' -import { getFinanceCustomerAccount, getFinanceCustomerTerms, updateFinanceCustomerTerms } from '../api/finance' -import type { FinanceAccountEntry, FinanceCustomerTerms } from '../types' - -interface AccountRow { - id: string - customerName: string - customerCode: string - creditDays: number - totalReceivable: number - totalReceived: number - outstandingAmount: number - entryCount: number - entries: FinanceAccountEntry[] - status: string -} +import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance' +import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types' const access = useAccessPreviewStore() -const customers = ref([]) -const customersLoading = ref(false) -const customersError = ref(null) -const filters = reactive({ customerId: '' }) -const rows = ref([]) +const filters = reactive({ keyword: '', status: '' }) +const rows = ref([]) const loading = ref(false) const loadError = ref(null) +const page = ref(1) +const pageSize = ref(20) +const total = ref(0) const drawerOpen = ref(false) const editing = ref(false) const saving = ref(false) -const selected = ref(null) +const selected = ref(null) +const ledger = ref([]) +const ledgerLoading = ref(false) +const ledgerError = ref(null) const selectedTerms = ref(null) const creditDays = ref(30) -const canView = computed(() => access.can('platform:finance:bill:view') && access.can('platform:finance:customer-terms:view')) -const canUpdate = computed(() => access.can('platform:finance:customer-terms:update')) -const stateError = computed(() => customersError.value || loadError.value) -const queryFields = computed(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: `${item.tenantName} (${item.tenantCode})`, value: String(item.id), disabled: item.status !== 'ACTIVE' })) }]) +const canView = computed(() => access.can('platform:finance:account:view')) +const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view')) +const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update')) +const queryFields: QueryField[] = [ + { key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '客户编号/客户名称/联系人/手机号', props: { 'aria-label': '关键词' } }, + { key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] }, +] const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' }, - { key: 'customerName', label: '客户', minWidth: 180 }, { key: 'customerCode', label: '客户编号', width: 150 }, - { key: 'creditDays', label: '账期(天)', width: 100 }, { key: 'totalReceivable', label: '累计应收(CNY)', width: 140 }, - { key: 'totalReceived', label: '累计已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, - { key: 'entryCount', label: '账户流水数', width: 120 }, + { key: 'customerName', label: '客户', minWidth: 180 }, + { key: 'customerCode', label: '客户编号', width: 150 }, + { key: 'contactName', label: '联系人', width: 120 }, + { key: 'contactPhone', label: '联系电话', width: 140 }, + { key: 'outstandingReceivable', label: '待收(CNY)', width: 130 }, + { key: 'pendingBilling', label: '待开账(CNY)', width: 140 }, + { key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 }, + { key: 'outstandingPayable', label: '待付(CNY)', width: 130 }, { key: 'status', label: '客户状态', width: 110, slotName: 'status' }, + { key: 'lastChangedAt', label: '最近变动', width: 170 }, ] -async function loadCustomers() { - customersLoading.value = true; customersError.value = null - try { - const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1 - do { const result = await listPlatformTenants({ page: currentPage, size: 200 }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages) - customers.value = records - } catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') } - finally { customersLoading.value = false } -} - async function load(model: QueryModel = filters) { Object.assign(filters, model) - const customerId = String(filters.customerId || '') - rows.value = []; loadError.value = null - if (!customerId) return - const customer = customers.value.find((item) => String(item.id) === customerId) - if (!customer) return + if (!canView.value) return loading.value = true + loadError.value = null try { - const [terms, account] = await Promise.all([getFinanceCustomerTerms(customerId), getFinanceCustomerAccount(customerId)]) - rows.value = [{ id: customerId, customerName: customer.tenantName, customerCode: customer.tenantCode, creditDays: terms.creditDays, totalReceivable: account.totalReceivable, totalReceived: account.totalReceived, outstandingAmount: account.outstandingAmount, entryCount: account.entries.length, entries: account.entries, status: customer.status }] + const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.keyword || '').trim(), status: String(filters.status || '') }) + rows.value = result.records + total.value = result.total } catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') } finally { loading.value = false } } - +function search(model: QueryModel) { page.value = 1; void load(model) } async function openAccount(row: Record, edit: boolean) { - const account = row as unknown as AccountRow - selected.value = account; editing.value = edit; drawerOpen.value = true; loadError.value = null - try { selectedTerms.value = await getFinanceCustomerTerms(account.id); creditDays.value = selectedTerms.value.creditDays } - catch (error) { loadError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') } + selected.value = row as unknown as FinanceAccountSummary + editing.value = edit + drawerOpen.value = true + ledger.value = [] + ledgerError.value = null + selectedTerms.value = null + if (edit) { + if (!canViewTerms.value) return + try { selectedTerms.value = await getFinanceCustomerTerms(selected.value.customerId); creditDays.value = selectedTerms.value.creditDays } + catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') } + return + } + ledgerLoading.value = true + try { + const result = await listFinanceAccountLedger(selected.value.customerId, { page: 1, size: 200 }) + ledger.value = result.records + } catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') } + finally { ledgerLoading.value = false } } +async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record, editing.value) } async function saveTerms() { if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return } saving.value = true - try { selectedTerms.value = await updateFinanceCustomerTerms(selected.value.id, { customerName: selected.value.customerName, creditDays: creditDays.value }); editing.value = false; ElMessage.success('客户账期已保存'); await load() } - catch (error) { loadError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(loadError.value.message) } + ledgerError.value = null + try { selectedTerms.value = await updateFinanceCustomerTerms(selected.value.customerId, { customerName: selected.value.customerName, creditDays: creditDays.value }); ElMessage.success('客户账期已保存') } + catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(ledgerError.value.message) } finally { saving.value = false } } function money(value: unknown) { return Number(value || 0).toFixed(2) } - -onMounted(() => void loadCustomers()) +onMounted(() => void load()) diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts index 066f9daa..1718fdcb 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts @@ -1,4 +1,4 @@ import { describe, expect, it } from 'vitest' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('