From e81490296e9583485c46b9139a1453b087150834 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 5 Sep 2026 08:34:40 +0800 Subject: [PATCH] feat(finance): add customer recharge account --- AGENTS.md | 3 +- src/modules/finance/api/account.ts | 99 ++++++ src/modules/finance/routes.ts | 10 +- .../finance/views/FinanceAccountView.test.mjs | 51 +++ .../finance/views/FinanceAccountView.vue | 308 ++++++++++++++++++ src/modules/tenant-management/contracts.ts | 4 + src/navigation/clientNavigation.test.mjs | 3 +- 7 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 src/modules/finance/api/account.ts create mode 100644 src/modules/finance/views/FinanceAccountView.test.mjs create mode 100644 src/modules/finance/views/FinanceAccountView.vue diff --git a/AGENTS.md b/AGENTS.md index 4293263..e454460 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## 项目定位 - 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。 -- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。 +- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。 - 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/跨区域认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。 - 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。 @@ -51,6 +51,7 @@ | 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` | | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 | +| 客户资金账户与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际、冻结、可用余额和不可变流水;充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 | | 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 | | 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID | | 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` | diff --git a/src/modules/finance/api/account.ts b/src/modules/finance/api/account.ts new file mode 100644 index 0000000..239a465 --- /dev/null +++ b/src/modules/finance/api/account.ts @@ -0,0 +1,99 @@ +import { apiRequest } from '@/api/http' + +export interface PageResult { + records: T[] + total: number + page: number + size: number + pages: number +} + +export interface FinanceAccount { + currencyCode: 'CNY' + actualBalance: string + frozenBalance: string + availableBalance: string + version: number + updatedAt: string +} + +export type AccountChangeType = 'RECHARGE' | 'DEDUCTION' | 'FREEZE' | 'UNFREEZE' | 'CONSUMPTION' + +export interface AccountLedgerEntry { + ledgerId: string + businessNo: string + changeType: AccountChangeType + amount: string + actualBalanceAfter: string + frozenBalanceAfter: string + availableBalanceAfter: string + sourceType: 'CLIENT_RECHARGE' | 'MANUAL_RECHARGE' | 'MANUAL_DEDUCTION' + sourceNo: string + operatorName: string | null + remark: string | null + createdAt: string +} + +export type RechargePaymentMethod = 'WECHAT' | 'ALIPAY' +export type RechargeStatus = 'PENDING' | 'APPROVED' | 'REJECTED' + +export interface RechargeRequest { + requestId: string + requestNo: string + amount: string + paymentMethod: RechargePaymentMethod + voucherFileId: string + voucherFileName: string + status: RechargeStatus + version: number + submittedByName: string | null + reviewedByName: string | null + reviewRemark: string | null + createdAt: string + reviewedAt: string | null +} + +export interface RechargeChannel { + paymentMethod: RechargePaymentMethod + displayName: string + enabled: boolean + qrCodeUrl: string | null +} + +export function getFinanceAccount() { + return apiRequest({ method: 'GET', url: '/client/finance/v1/account' }) +} + +export function getAccountLedger(page: number, size: number) { + return apiRequest>({ + method: 'GET', + url: '/client/finance/v1/account/ledger', + params: { page, size }, + }) +} + +export function getRechargeRequests(page: number, size: number) { + return apiRequest>({ + method: 'GET', + url: '/client/finance/v1/recharge-requests', + params: { page, size }, + }) +} + +export function getRechargeChannels() { + return apiRequest({ method: 'GET', url: '/client/finance/v1/recharge-channels' }) +} + +export function createRechargeRequest(amount: string, paymentMethod: RechargePaymentMethod, file: File, + idempotencyKey: string) { + const formData = new FormData() + formData.append('amount', amount) + formData.append('paymentMethod', paymentMethod) + formData.append('file', file, file.name) + return apiRequest({ + method: 'POST', + url: '/client/finance/v1/recharge-requests', + headers: { 'Idempotency-Key': idempotencyKey }, + data: formData, + }) +} diff --git a/src/modules/finance/routes.ts b/src/modules/finance/routes.ts index 278f7bf..f9cb794 100644 --- a/src/modules/finance/routes.ts +++ b/src/modules/finance/routes.ts @@ -1,10 +1,18 @@ -import { ReceiptText } from '@lucide/vue' +import { CircleDollarSign, ReceiptText } from '@lucide/vue' import type { ClientModuleManifest } from '../types.ts' export const financeModule = { key: 'finance', navigationGroup: { title: '财务结算', icon: ReceiptText }, routes: [ + { + path: 'finance/account', + name: 'finance-account', + component: () => import('./views/FinanceAccountView.vue'), + release: 'active', + meta: { title: '资金账户', description: '查看本企业资金余额、流水与充值申请', permission: 'merchant:finance:account:view' }, + navigation: { key: 'finance-account', title: '资金账户', icon: CircleDollarSign }, + }, { path: 'finance/bills', name: 'finance-bills', diff --git a/src/modules/finance/views/FinanceAccountView.test.mjs b/src/modules/finance/views/FinanceAccountView.test.mjs new file mode 100644 index 0000000..38c91d5 --- /dev/null +++ b/src/modules/finance/views/FinanceAccountView.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const apiSource = readFileSync(new URL('../api/account.ts', import.meta.url), 'utf8') +const viewSource = readFileSync(new URL('./FinanceAccountView.vue', import.meta.url), 'utf8') +const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8') +const permissionsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8') + +test('uses only authenticated client finance account endpoints without customer scope', () => { + for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests', '/client/finance/v1/recharge-channels']) { + assert.match(apiSource, new RegExp(path.replaceAll('/', '\\/'))) + } + assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/) + assert.match(apiSource, /formData\.append\('amount', amount\)/) + assert.match(apiSource, /formData\.append\('paymentMethod', paymentMethod\)/) + assert.match(apiSource, /formData\.append\('file', file, file\.name\)/) + assert.doesNotMatch(apiSource, /customerId|tenantId|mock|fallback/i) +}) + +test('activates the account route and maps read and recharge permissions independently', () => { + assert.match(routesSource, /path: 'finance\/account'[\s\S]*FinanceAccountView\.vue[\s\S]*release: 'active'[\s\S]*permission: 'merchant:finance:account:view'/) + assert.match(permissionsSource, /'merchant:finance:account:view': \['merchant:finance:account:view'\]/) + assert.match(permissionsSource, /'merchant:finance:recharge:create': \['merchant:finance:recharge:create'\]/) + assert.match(viewSource, /auth\.hasPermission\('merchant:finance:recharge:create'\)/) + assert.match(viewSource, / { + for (const field of ['actualBalance', 'frozenBalance', 'availableBalance']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`)) + assert.match(viewSource, /row-key="businessNo"/) + assert.match(viewSource, /row-key="requestNo"/) + assert.match(viewSource, /empty-text="暂无资金流水"/) + assert.match(viewSource, /empty-text="暂无充值申请"/) + assert.match(viewSource, /account-state--error/) + assert.match(viewSource, /v-loading="pageLoading"/) + assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/) +}) + +test('matches the frozen recharge workflow and preserves an idempotency key for failed retries', () => { + assert.match(viewSource, /title="账户充值" width="760px"/) + for (const amount of ['50.00', '100.00', '200.00', '500.00']) assert.match(viewSource, new RegExp(amount.replace('.', '\\.'))) + assert.match(viewSource, /\['WECHAT', 'ALIPAY'\]/) + assert.match(viewSource, /selectedChannel\.value\?\.enabled && selectedChannel\.value\.qrCodeUrl/) + assert.match(viewSource, /accept="\.jpg,\.jpeg,\.png,image\/jpeg,image\/png"/) + assert.match(viewSource, /if \(!pendingIdempotencyKey\)[\s\S]*globalThis\.crypto\?\.randomUUID\?\.\(\)/) + assert.match(viewSource, /pendingIdempotencyKey = ''[\s\S]*rechargeVisible\.value = false/) + assert.match(viewSource, /:disabled="!canSubmit"/) + assert.match(viewSource, /await loadAccountData\(\)/) + assert.doesNotMatch(viewSource, /payment SDK|支付成功|模拟成功/) +}) diff --git a/src/modules/finance/views/FinanceAccountView.vue b/src/modules/finance/views/FinanceAccountView.vue new file mode 100644 index 0000000..14a0585 --- /dev/null +++ b/src/modules/finance/views/FinanceAccountView.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index 4930f94..b19e0dd 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -38,6 +38,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ key: 'finance', label: '财务账单', items: [ + { code: 'merchant:finance:account:view', label: '查看资金账户', description: '查看本企业实际、冻结和可用余额及资金流水' }, + { code: 'merchant:finance:recharge:create', label: '提交充值申请', description: '上传付款截图并提交本企业充值申请' }, { code: 'finance:bill:view', label: '查看客户账单', description: '查看本企业账单、收款记录和未收余额' }, { code: 'finance:bill:confirm', label: '确认客户账单', description: '确认本企业已出账且待客户确认的对账单' }, ], @@ -93,6 +95,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ ] export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly> = { + 'merchant:finance:account:view': ['merchant:finance:account:view'], + 'merchant:finance:recharge:create': ['merchant:finance:recharge:create'], 'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'], 'order:first-leg:create': ['merchant:order:create'], 'order:first-leg:quick-create': ['merchant:order:quick-create', 'merchant:order:create'], diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs index 7029442..ca9604d 100644 --- a/src/navigation/clientNavigation.test.mjs +++ b/src/navigation/clientNavigation.test.mjs @@ -67,7 +67,7 @@ test('keeps only implemented navigation groups visible', () => { }, { title: '财务结算', - children: ['客户账单'], + children: ['资金账户', '客户账单'], }, { title: '企业管理', @@ -108,6 +108,7 @@ test('flattens visible leaves with unique paths', () => { '/overseas-warehouse/return-relabel', '/orders/inbound', '/orders/delivery', + '/finance/account', '/finance/bills', '/company/profile', '/organization/members',