From 255e012c1bfac70853a7ba9d3ee65c6991add459 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 11:59:58 +0800
Subject: [PATCH 01/33] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index df7c55f..c889e1e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,7 @@
| 业务 | 模块入口 | 联查模块或服务端 |
|---|---|---|
| 登录、注册与会话 | `src/modules/auth/` | Backend `auth/`、`iam/` |
-| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`file/` |
+| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
| 头程下单、订单、入仓码业务关系、箱唛与履约进度 | `src/modules/first-leg/`;列表从 `views/FirstLegOrdersView.vue` 进入,点击入仓码由 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` 查询订单、客户和目的仓关联摘要;箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/` 的客户隔离 `InboundCodeAssociationService`、`order-v1.yaml`,以及 `firstleg/`、`warehouse/`;不得用内部 ID 补齐缺失业务编号 |
From 517b6718f005b3bb0610281f8266cd408f4ee6c5 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 13:34:56 +0800
Subject: [PATCH 02/33] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../views/CompanyProfileView.vue | 20 +++++++------------
.../views/TenantAdministrationViews.test.mjs | 8 +++++---
2 files changed, 12 insertions(+), 16 deletions(-)
diff --git a/src/modules/tenant-management/views/CompanyProfileView.vue b/src/modules/tenant-management/views/CompanyProfileView.vue
index e9ea571..ea52697 100644
--- a/src/modules/tenant-management/views/CompanyProfileView.vue
+++ b/src/modules/tenant-management/views/CompanyProfileView.vue
@@ -83,7 +83,6 @@ const normalizedQualificationCertificateNo = computed(() => qualificationCertifi
const draftCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
const businessLicenseFileSelected = computed(() => Boolean(qualificationDraftItem('BUSINESS_LICENSE')?.file))
const legalPersonIdFileSelected = computed(() => Boolean(qualificationDraftItem('LEGAL_PERSON_ID')?.file))
-const standaloneLegalPersonIdUnsupported = computed(() => legalPersonIdFileSelected.value && !businessLicenseFileSelected.value)
const requiredPackageChangeSelected = computed(() => businessLicenseFileSelected.value)
const requiredPackageFilesSelected = computed(() => businessLicenseFileSelected.value)
const qualificationCreditCodeReady = computed(() => requiredPackageChangeSelected.value
@@ -122,11 +121,9 @@ const qualificationUploadDisabledReason = computed(() => {
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
const canSaveQualificationBatch = computed(() => canUploadQualification.value
&& selectedQualificationCount.value > 0
- && !standaloneLegalPersonIdUnsupported.value
&& requiredPackageComplete.value)
const qualificationSaveDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
- if (standaloneLegalPersonIdUnsupported.value) return '法人身份证为选填项;当前文件服务尚未开放单独保存,请取消本次选择后保存其他资质。'
if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`
if (!selectedQualificationCount.value) return '当前没有需要保存的变更'
return ''
@@ -340,20 +337,15 @@ async function submitQualification() {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
- if (standaloneLegalPersonIdUnsupported.value) {
- const legalPersonId = qualificationDraftItem('LEGAL_PERSON_ID')
- const message = '当前文件服务尚未开放法人身份证单独上传或替换,该选填文件未提交。'
- if (legalPersonId) { legalPersonId.error = message; legalPersonId.uploadState = 'error' }
- ElMessage.warning(message)
- return
- }
if (!requiredPackageComplete.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
return
}
- const optionalItems = qualificationDrafts.filter((item) => !item.required && item.value !== 'LEGAL_PERSON_ID' && item.file)
+ const optionalItems = qualificationDrafts.filter((item) => !item.required
+ && item.file
+ && (item.value !== 'LEGAL_PERSON_ID' || !requiredPackageChangeSelected.value))
const includedLegalPersonId = legalPersonIdFileSelected.value
qualificationSubmitting.value = true
let uploadedCount = 0
@@ -430,8 +422,10 @@ async function submitQualification() {
}
qualificationOpen.value = false
if (refreshed) ElMessage.success(includedLegalPersonId
- ? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
- : '营业执照和统一社会信用代码已保存,尚未提交审核')
+ ? (requiredPackageChangeSelected.value
+ ? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
+ : '法人身份证已保存,尚未提交审核')
+ : '资质文件已保存,尚未提交审核')
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
} finally {
qualificationSubmitting.value = false
diff --git a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs
index 2816e3d..2967085 100644
--- a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs
+++ b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs
@@ -73,12 +73,14 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /requiredQualificationTypes\.every/)
assert.doesNotMatch(profileSource, /四类必填资质/)
assert.doesNotMatch(profileSource, /法人身份证需与本次营业执照一起保存/)
- assert.match(profileSource, /const standaloneLegalPersonIdUnsupported = computed/)
- assert.match(profileSource, /当前文件服务尚未开放法人身份证单独上传或替换,该选填文件未提交/)
+ assert.doesNotMatch(profileSource, /standaloneLegalPersonIdUnsupported/)
+ assert.doesNotMatch(profileSource, /当前文件服务尚未开放法人身份证单独上传或替换/)
+ assert.match(profileSource, /item\.value !== 'LEGAL_PERSON_ID' \|\| !requiredPackageChangeSelected\.value/)
+ assert.match(profileSource, /法人身份证已保存,尚未提交审核/)
assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/)
assert.match(profileSource, /submitTenantQualificationReview/)
assert.match(profileSource, /提交企业审核/)
- assert.match(profileSource, /营业执照和统一社会信用代码已保存,尚未提交审核/)
+ assert.match(profileSource, /资质文件已保存,尚未提交审核/)
assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/)
assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/)
assert.match(profileSource, /qualificationStatusMeta/)
From 763687177414c2c0567c1efcf8ba1b456726d2a0 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 14:44:48 +0800
Subject: [PATCH 03/33] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/AGENTS.md b/AGENTS.md
index df7c55f..aa735c0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,6 +58,7 @@
- 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。
- 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。
+- 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `contactPhone`,个人资料或成员管理中的登录手机号由 IAM/Auth 同步并撤销该账号现有会话,不得在前端互相回填或联动提交。
- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;国内请求使用国内 Access Token,海外请求统一由 `src/api/overseasHttp.ts` 读取短时海外 Token。海外 Token 缺失或 401 时复用同一刷新编排并最多重放一次,页面不要复制令牌逻辑或持久化密码、敏感资质内容。
- 当前认证仍包含模拟数据。接入真实 API 后移除对应模拟分支,生产环境不得回退为模拟成功。
- 头程订单统一使用当前正式读写契约;不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时按根无兼容规则同步删除生产分支、类型、文案、Mock 和测试,不以现有代码或测试仍覆盖旧路径作为保留依据。
From 399243974b0e8b501c004392fc951b5bd894aca0 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 15:15:44 +0800
Subject: [PATCH 04/33] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index c889e1e..dc442ba 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,7 @@
| 业务 | 模块入口 | 联查模块或服务端 |
|---|---|---|
| 登录、注册与会话 | `src/modules/auth/` | Backend `auth/`、`iam/` |
-| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 |
+| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换 |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
| 头程下单、订单、入仓码业务关系、箱唛与履约进度 | `src/modules/first-leg/`;列表从 `views/FirstLegOrdersView.vue` 进入,点击入仓码由 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` 查询订单、客户和目的仓关联摘要;箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/` 的客户隔离 `InboundCodeAssociationService`、`order-v1.yaml`,以及 `firstleg/`、`warehouse/`;不得用内部 ID 补齐缺失业务编号 |
From 6d10251161b127d8e86e83ccc916053f80fd778d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 20:12:43 +0800
Subject: [PATCH 05/33] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=A2=E6=88=B7?=
=?UTF-8?q?=E8=B4=A6=E5=8D=95=E6=94=B6=E6=AC=BE=E6=96=B9=E5=BC=8F=E5=B1=95?=
=?UTF-8?q?=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/modules/finance/views/FinanceBillsView.test.mjs | 2 ++
src/modules/finance/views/FinanceBillsView.vue | 7 ++++++-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/modules/finance/views/FinanceBillsView.test.mjs b/src/modules/finance/views/FinanceBillsView.test.mjs
index 7ad3278..5437514 100644
--- a/src/modules/finance/views/FinanceBillsView.test.mjs
+++ b/src/modules/finance/views/FinanceBillsView.test.mjs
@@ -120,6 +120,8 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
for (const expected of ['未收余额', '账单详情', '确认账单', '客户确认信息', '收款记录', '暂无账单', '暂无收款记录', 'state-panel--error']) {
assert.match(viewSource, new RegExp(expected))
}
+ assert.match(viewSource, /BANK_TRANSFER: '银行转账'/)
+ assert.match(viewSource, /paymentMethodLabel\(row\.method\)/)
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
assert.match(viewSource, /row\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(viewSource, /size="small" type="success" plain/)
diff --git a/src/modules/finance/views/FinanceBillsView.vue b/src/modules/finance/views/FinanceBillsView.vue
index 87be647..95fd113 100644
--- a/src/modules/finance/views/FinanceBillsView.vue
+++ b/src/modules/finance/views/FinanceBillsView.vue
@@ -49,6 +49,11 @@ function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '-'
}
+function paymentMethodLabel(method: string | null | undefined) {
+ if (!method) return '—'
+ return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' })[method] ?? '其他'
+}
+
function statusLabel(status: FinanceBillStatus) {
return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' })[status] ?? status
}
@@ -207,7 +212,7 @@ onMounted(() => { void loadBills(1) })
{{ detail.billNo }}
出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }} · 未收 ¥ {{ formatAmount(detail.outstandingAmount) }}
{{ statusLabel(detail.status) }}{{ confirmationLabel(detail.customerConfirmationStatus) }}
{{ confirmationLabel(detail.customerConfirmationStatus) }}{{ detail.customerConfirmedByName || '当前企业成员' }} · {{ formatDateTime(detail.customerConfirmedAt) }}
请核对本期金额和费用明细,确认后将记录当前登录成员与确认时间。
确认账单
费用明细
¥ {{ formatAmount(row.amount) }}
- 收款记录
{{ formatDate(row.receivedAt) }}¥ {{ formatAmount(row.allocatedAmount) }}暂无收款记录
+ 收款记录
{{ formatDate(row.receivedAt) }}¥ {{ formatAmount(row.allocatedAmount) }}{{ paymentMethodLabel(row.method) }}暂无收款记录
如对账单内容有疑问,请先线下联系平台财务,核对无误后再确认。
From 12424281c79c8b0e93eec8b59665db55bd00a542 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 20:25:45 +0800
Subject: [PATCH 06/33] feat(finance): link bill lines to inbound details
---
src/modules/finance/api/adapters.ts | 2 ++
src/modules/finance/contracts.ts | 1 +
src/modules/finance/views/FinanceBillsView.test.mjs | 8 ++++++--
src/modules/finance/views/FinanceBillsView.vue | 13 ++++++++++++-
src/modules/first-leg/public.ts | 1 +
5 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/src/modules/finance/api/adapters.ts b/src/modules/finance/api/adapters.ts
index 33ea5c8..10aaa1a 100644
--- a/src/modules/finance/api/adapters.ts
+++ b/src/modules/finance/api/adapters.ts
@@ -34,6 +34,7 @@ export interface FinanceBillLineWire {
sourceVersion: number
orderId: WireScalar | null
inboundId: WireScalar | null
+ inboundCode: string | null
feeItemCode: string
feeItemName: string
amount: WireScalar
@@ -89,6 +90,7 @@ function mapBillLine(line: FinanceBillLineWire): FinanceBillLine {
return {
id: text(line.id),
orderId: line.orderId == null ? null : text(line.orderId),
+ inboundCode: line.inboundCode?.trim() || null,
feeNo: `${line.sourceType}:${text(line.sourceId)}/v${line.sourceVersion}`,
feeName: line.feeItemName,
amount: text(line.amount),
diff --git a/src/modules/finance/contracts.ts b/src/modules/finance/contracts.ts
index 0eaafac..142ac2f 100644
--- a/src/modules/finance/contracts.ts
+++ b/src/modules/finance/contracts.ts
@@ -39,6 +39,7 @@ export interface FinanceCustomerConfirmationResult {
export interface FinanceBillLine {
id: string
orderId: string | null
+ inboundCode: string | null
feeNo: string | null
feeName: string
amount: string
diff --git a/src/modules/finance/views/FinanceBillsView.test.mjs b/src/modules/finance/views/FinanceBillsView.test.mjs
index 5437514..43818db 100644
--- a/src/modules/finance/views/FinanceBillsView.test.mjs
+++ b/src/modules/finance/views/FinanceBillsView.test.mjs
@@ -92,7 +92,7 @@ test('maps nested bill detail and customer-safe receipt allocations', () => {
receivedAmount: '40.00', outstandingAmount: '60.00',
version: 3, customerConfirmationStatus: 'PENDING_CONFIRMATION', customerConfirmedByName: null, customerConfirmedAt: null,
},
- lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
+ lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, inboundCode: 'NYEXX2608180003', feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
})
const receipts = financeAdapters.mapFinanceReceipts([{
id: 51, receiptNo: 'RCPT-51', receivedAmount: '40.00', receivedAt: '2026-08-12T10:00:00',
@@ -101,7 +101,7 @@ test('maps nested bill detail and customer-safe receipt allocations', () => {
}], '2')
assert.equal(detail.billNo, 'B-2')
- assert.deepEqual(detail.lines[0], { id: '21', orderId: '31', feeNo: 'INBOUND_FEE:81/v3', feeName: '运费', amount: '100.00', currencyCode: 'CNY' })
+ assert.deepEqual(detail.lines[0], { id: '21', orderId: '31', inboundCode: 'NYEXX2608180003', feeNo: 'INBOUND_FEE:81/v3', feeName: '运费', amount: '100.00', currencyCode: 'CNY' })
assert.deepEqual(receipts[0], { id: '51', receiptNo: 'RCPT-51', receivedAt: '2026-08-12T10:00:00', amount: '40.00', currencyCode: 'CNY', method: 'BANK_TRANSFER', allocatedAmount: '40.00' })
})
@@ -122,6 +122,10 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
}
assert.match(viewSource, /BANK_TRANSFER: '银行转账'/)
assert.match(viewSource, /paymentMethodLabel\(row\.method\)/)
+ assert.match(viewSource, /label="入仓码"/)
+ assert.match(viewSource, /openInboundAssociation\(row\.inboundCode\)/)
+ assert.match(viewSource, /InboundCodeAssociationDialog/)
+ assert.match(viewSource, /:inbound-code="inboundAssociationCode"/)
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
assert.match(viewSource, /row\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(viewSource, /size="small" type="success" plain/)
diff --git a/src/modules/finance/views/FinanceBillsView.vue b/src/modules/finance/views/FinanceBillsView.vue
index 95fd113..d4f46df 100644
--- a/src/modules/finance/views/FinanceBillsView.vue
+++ b/src/modules/finance/views/FinanceBillsView.vue
@@ -4,6 +4,7 @@ import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/modules/auth/public'
+import { InboundCodeAssociationDialog } from '@/modules/first-leg/public'
import { confirmMerchantFinanceStatement, getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
import type { FinanceBillDetail, FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
@@ -16,6 +17,8 @@ const bills = ref([])
const detail = ref(null)
const selectedBillId = ref('')
const detailVisible = ref(false)
+const inboundAssociationVisible = ref(false)
+const inboundAssociationCode = ref('')
const confirmingBillId = ref('')
const confirmationKeys = new Map()
const page = ref(1)
@@ -160,6 +163,13 @@ function handlePageChange(nextPage: number) {
void loadBills(nextPage)
}
+function openInboundAssociation(inboundCode: string | null) {
+ const code = inboundCode?.trim()
+ if (!code) return
+ inboundAssociationCode.value = code
+ inboundAssociationVisible.value = true
+}
+
onMounted(() => { void loadBills(1) })
@@ -211,12 +221,13 @@ onMounted(() => { void loadBills(1) })
{{ detail.billNo }}
出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }} · 未收 ¥ {{ formatAmount(detail.outstandingAmount) }}
{{ statusLabel(detail.status) }}{{ confirmationLabel(detail.customerConfirmationStatus) }}
{{ confirmationLabel(detail.customerConfirmationStatus) }}{{ detail.customerConfirmedByName || '当前企业成员' }} · {{ formatDateTime(detail.customerConfirmedAt) }}
请核对本期金额和费用明细,确认后将记录当前登录成员与确认时间。
确认账单
- 费用明细
¥ {{ formatAmount(row.amount) }}
+ 费用明细
{{ row.inboundCode }}—¥ {{ formatAmount(row.amount) }}
收款记录
{{ formatDate(row.receivedAt) }}¥ {{ formatAmount(row.allocatedAmount) }}{{ paymentMethodLabel(row.method) }}暂无收款记录
如对账单内容有疑问,请先线下联系平台财务,核对无误后再确认。
+
diff --git a/src/modules/first-leg/public.ts b/src/modules/first-leg/public.ts
index a7a171b..50e9e80 100644
--- a/src/modules/first-leg/public.ts
+++ b/src/modules/first-leg/public.ts
@@ -1,2 +1,3 @@
export { useFirstLegOrdersStore } from './store/firstLegOrders'
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
+export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue'
From 4ee8ac3ee7602c6326e6bf760d6d59f69706b0ab Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 20:41:51 +0800
Subject: [PATCH 07/33] fix(deploy): prevent stale SPA entry caching
---
nginx/default.conf.template | 14 ++++++++++++++
src/deployment/nginxConfig.test.mjs | 6 ++++++
2 files changed, 20 insertions(+)
diff --git a/nginx/default.conf.template b/nginx/default.conf.template
index 2c714ab..68ea42b 100644
--- a/nginx/default.conf.template
+++ b/nginx/default.conf.template
@@ -11,6 +11,19 @@ server {
return 200 "ok\n";
}
+ # SPA 入口每次重新校验,避免发布后浏览器继续运行旧资源清单。
+ location = /index.html {
+ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
+ add_header Pragma "no-cache" always;
+ add_header Expires "0" always;
+ }
+
+ # Vite 产物文件名带内容哈希,可安全长期缓存。
+ location /assets/ {
+ try_files $uri =404;
+ add_header Cache-Control "public, max-age=31536000, immutable" always;
+ }
+
# 前端统一请求 /api,保持原始路径转发给 Gateway。
location /api/ {
client_max_body_size 55m;
@@ -36,6 +49,7 @@ server {
# 支持 Vue Router history 路由直接刷新。
location / {
+ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri $uri/ /index.html;
}
}
diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs
index c32b26a..aab81bb 100644
--- a/src/deployment/nginxConfig.test.mjs
+++ b/src/deployment/nginxConfig.test.mjs
@@ -13,6 +13,12 @@ test('allows 50 MiB uploads with multipart overhead through both API proxies', (
assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2)
})
+test('does not cache the SPA entry while keeping hashed assets immutable', () => {
+ assert.match(config, /location = \/index\.html[\s\S]*no-store, no-cache, must-revalidate/)
+ assert.match(config, /location \/assets\/[\s\S]*max-age=31536000, immutable/)
+ assert.match(config, /location \/ \{[\s\S]*no-store, no-cache, must-revalidate[\s\S]*try_files \$uri \$uri\/ \/index\.html/)
+})
+
test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => {
assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/)
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/)
From a7c36771e422f686c12dd7de0c282a0254ea3677 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 2 Sep 2026 20:49:13 +0800
Subject: [PATCH 08/33] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/App.vue | 42 +++++++++++++++++------
src/modules/auth/store/auth.ts | 9 -----
src/modules/auth/views/AuthViews.test.mjs | 9 ++++-
src/router/index.ts | 3 +-
4 files changed, 41 insertions(+), 22 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index f38f8d2..77311e0 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,32 +1,52 @@
diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts
index 574c1f4..76e63fb 100644
--- a/src/modules/auth/store/auth.ts
+++ b/src/modules/auth/store/auth.ts
@@ -223,15 +223,6 @@ export const useAuthStore = defineStore('client-auth', () => {
}
window.addEventListener('cc-client-session-expired', clearSession)
- window.addEventListener('message', (event: MessageEvent) => {
- const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
- const expectedAdminOrigin = configuredAdminOrigin || window.location.origin
- const message = event.data as { source?: string; type?: string; code?: string } | null
- if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code) return
- void acceptImpersonationCode(message.code)
- .then(() => window.location.replace('/'))
- .catch(() => window.location.replace('/login?impersonation=failed'))
- })
window.addEventListener('storage', (event) => {
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
})
diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs
index 6adc8fb..e9d911c 100644
--- a/src/modules/auth/views/AuthViews.test.mjs
+++ b/src/modules/auth/views/AuthViews.test.mjs
@@ -13,6 +13,8 @@ const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url)
const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8')
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
const mainSource = readFileSync(new URL('../../../main.ts', import.meta.url), 'utf8')
+const appSource = readFileSync(new URL('../../../App.vue', import.meta.url), 'utf8')
+const routerSource = readFileSync(new URL('../../../router/index.ts', import.meta.url), 'utf8')
const stylesSource = readFileSync(new URL('../../../styles/index.css', import.meta.url), 'utf8')
test('uses one prototype-inspired responsive shell for login and registration', () => {
@@ -105,7 +107,12 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
test('handles impersonation exchange as public authentication and exposes failures', () => {
assert.match(httpSource, /publicAuthPaths = \[[^\]]*'\/auth\/impersonations\/exchange'/)
- assert.match(authStoreSource, /\/login\?impersonation=failed/)
+ assert.match(appSource, /impersonationOrigin/)
+ assert.match(appSource, /await auth\.acceptImpersonationCode\(message\.code\)/)
+ assert.match(appSource, /type: 'impersonation-complete'/)
+ assert.match(appSource, /type: 'impersonation-failed'/)
+ assert.doesNotMatch(authStoreSource, /message\.type !== 'impersonation-ticket'/)
+ assert.match(routerSource, /isImpersonationHandoff[\s\S]*!isImpersonationHandoff/)
assert.match(loginSource, /route\.query\.impersonation === 'failed'/)
assert.match(loginSource, /代登录失败或授权已失效,请返回管理后台重新发起/)
})
diff --git a/src/router/index.ts b/src/router/index.ts
index 0280d8c..679f504 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -32,7 +32,8 @@ router.beforeEach(async (to) => {
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
- if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated) {
+ const isImpersonationHandoff = to.name === 'login' && typeof to.query.impersonationOrigin === 'string'
+ if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated && !isImpersonationHandoff) {
return getFirstAccessiblePath(auth.permissionCodes)
}
const permission = typeof to.meta.permission === 'string' ? to.meta.permission : undefined
From 6780228f86bd28b6813aea156dbb82767ac25f2e Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Thu, 3 Sep 2026 10:46:14 +0800
Subject: [PATCH 09/33] =?UTF-8?q?=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/modules/first-leg/api/inboundBilling.ts | 35 ++++++++++++
.../components/InboundBillingPanel.test.mjs | 6 +-
.../components/InboundBillingPanel.vue | 56 +++++++++++++++++--
src/modules/first-leg/types/inboundBilling.ts | 38 +++++++++++++
.../views/FirstLegOrdersView.test.mjs | 4 +-
.../first-leg/views/FirstLegOrdersView.vue | 19 +++++--
6 files changed, 145 insertions(+), 13 deletions(-)
diff --git a/src/modules/first-leg/api/inboundBilling.ts b/src/modules/first-leg/api/inboundBilling.ts
index fef4283..befba7b 100644
--- a/src/modules/first-leg/api/inboundBilling.ts
+++ b/src/modules/first-leg/api/inboundBilling.ts
@@ -9,6 +9,9 @@ import type {
interface MerchantInboundBillingWireResponse {
inbound: MerchantInboundResult
+ measurementSummary: MerchantInboundBilling['measurementSummary']
+ measurementItems: MerchantInboundBilling['measurementItems']
+ billingBasis: MerchantInboundBilling['billingBasis']
latestFee: MerchantInboundFeeSnapshot
}
@@ -101,6 +104,35 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
}
}
+function sanitizeMeasurementSummary(item: MerchantInboundBilling['measurementSummary']): MerchantInboundBilling['measurementSummary'] {
+ return {
+ measurementId: item.measurementId == null ? null : String(item.measurementId),
+ sourceType: item.sourceType,
+ measurementStatus: item.measurementStatus,
+ actualPackageCount: item.actualPackageCount,
+ actualGrossWeightKg: item.actualGrossWeightKg,
+ actualNetWeightKg: item.actualNetWeightKg,
+ actualVolumeCbm: item.actualVolumeCbm,
+ volumeWeightKg: item.volumeWeightKg,
+ actualGoodsName: item.actualGoodsName,
+ }
+}
+
+function sanitizeMeasurementItem(item: MerchantInboundBilling['measurementItems'][number]): MerchantInboundBilling['measurementItems'][number] {
+ return {
+ id: String(item.id),
+ itemNo: item.itemNo,
+ boxSpecification: item.boxSpecification,
+ packageCount: item.packageCount,
+ lengthCm: item.lengthCm,
+ widthCm: item.widthCm,
+ heightCm: item.heightCm,
+ unitVolumeCbm: item.unitVolumeCbm,
+ totalVolumeCbm: item.totalVolumeCbm,
+ volumeWeightKg: item.volumeWeightKg,
+ }
+}
+
function sanitizeInboundBilling(response: MerchantInboundBillingWireResponse): MerchantInboundBilling {
return {
inbound: {
@@ -108,6 +140,9 @@ function sanitizeInboundBilling(response: MerchantInboundBillingWireResponse): M
publicRemark: response.inbound.publicRemark,
palletLocations: response.inbound.palletLocations.map(sanitizePalletLocation),
},
+ measurementSummary: sanitizeMeasurementSummary(response.measurementSummary),
+ measurementItems: (response.measurementItems || []).map(sanitizeMeasurementItem),
+ billingBasis: response.billingBasis,
latestFee: sanitizeFee(response.latestFee),
}
}
diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs
index 083ba22..4669d84 100644
--- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs
+++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs
@@ -14,13 +14,15 @@ test('renders loading, pending, isolated error, and published states', () => {
assert.match(source, /\$emit\('retry'\)/)
})
-test('shows the confirmed merchant inbound and fee fields without write actions', () => {
+test('shows the confirmed merchant inbound, measurement details and customer confirmation entry', () => {
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
assert.match(source, /formatConversionPrecision\(/)
- assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/)
+ assert.match(source, /确认费用/)
+ assert.match(source, /measurementItems/)
+ assert.doesNotMatch(source, /调整费用|发送费用|下载照片/)
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
assert.match(source, /latestFee\.ruleCode/)
assert.match(source, /latestFee\.rateCode/)
diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue
index 29f3fa3..a857566 100644
--- a/src/modules/first-leg/components/InboundBillingPanel.vue
+++ b/src/modules/first-leg/components/InboundBillingPanel.vue
@@ -1,4 +1,5 @@
入库与费用
- 待分柜 · 费用已发布
+
+ 待分柜 · 费用已发布
+ 确认费用
+
@@ -55,7 +66,7 @@ defineEmits<{
{{ props.unavailableMessage }}
- 客服完成测量审核、入库并发布费用后,将在这里展示最终结果。
+ 平台完成测量审核并确认推送费用后,将在这里展示测量与计费结果。
@@ -68,7 +79,7 @@ defineEmits<{
-
实测结果
+
测量数据
后台推送
- 实际箱数
- {{ formatInboundNumber(props.data.inbound.summary.totalPackageCount, 0) }}
- 实际毛重
- {{ formatInboundMeasure(props.data.inbound.summary.actualGrossWeightKg, 'kg') }}
@@ -77,6 +88,17 @@ defineEmits<{
- 材积重
- {{ formatInboundMeasure(props.data.inbound.summary.volumeWeightKg, 'kg') }}
- 测量完成
- {{ formatInboundDateTime(props.data.inbound.summary.measureCompletedAt) }}
+
+
+
+
+
+
+ {{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}
+ {{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}
+ {{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}
+
+
@@ -138,12 +160,36 @@ defineEmits<{
{{ props.data.inbound.publicRemark }}
+
+
+
+ - 测量总箱数
- {{ formatInboundNumber(props.data.measurementSummary.actualPackageCount, 0) }}
+ - 测量总毛重
- {{ formatInboundMeasure(props.data.measurementSummary.actualGrossWeightKg, 'kg') }}
+ - 测量总体积
- {{ formatInboundMeasure(props.data.measurementSummary.actualVolumeCbm, 'm³') }}
+ - 测量总材积重
- {{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}
+ - 计费体积
- {{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}
+ - 计费重量
- {{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}
+
+
+
+
+
+
+
+ {{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}
+ {{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}
+ {{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}
+
+
+ 确认
+
diff --git a/src/modules/cross-border/routes.ts b/src/modules/cross-border/routes.ts
index e0a1c4e..1d54e92 100644
--- a/src/modules/cross-border/routes.ts
+++ b/src/modules/cross-border/routes.ts
@@ -9,7 +9,7 @@ export const crossBorderModule = {
path: 'cross-border/procurement',
name: 'cross-border-procurement',
component: () => import('./views/ProcurementView.vue'),
- release: 'prototype',
+ release: 'active',
meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' },
navigation: { key: 'cross-border-procurement', icon: Package },
},
@@ -17,7 +17,7 @@ export const crossBorderModule = {
path: 'cross-border/domestic-trucking',
name: 'cross-border-domestic-trucking',
component: () => import('./views/DomesticTruckingView.vue'),
- release: 'prototype',
+ release: 'active',
meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' },
navigation: { key: 'cross-border-domestic-trucking', icon: Truck },
},
@@ -25,7 +25,7 @@ export const crossBorderModule = {
path: 'cross-border/pallet-shipping',
name: 'cross-border-pallet-shipping',
component: () => import('./views/PalletShippingView.vue'),
- release: 'prototype',
+ release: 'active',
meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' },
navigation: { key: 'cross-border-pallet-shipping', icon: Ship },
},
diff --git a/src/modules/cross-border/views/DomesticTruckingView.vue b/src/modules/cross-border/views/DomesticTruckingView.vue
index d3ecc7e..bc9e623 100644
--- a/src/modules/cross-border/views/DomesticTruckingView.vue
+++ b/src/modules/cross-border/views/DomesticTruckingView.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/src/modules/cross-border/views/PalletShippingView.vue b/src/modules/cross-border/views/PalletShippingView.vue
index e5cec3f..0f4ee30 100644
--- a/src/modules/cross-border/views/PalletShippingView.vue
+++ b/src/modules/cross-border/views/PalletShippingView.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue
index edb2695..bde268f 100644
--- a/src/modules/cross-border/views/ProcurementView.vue
+++ b/src/modules/cross-border/views/ProcurementView.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/src/modules/dashboard/views/DashboardView.test.mjs b/src/modules/dashboard/views/DashboardView.test.mjs
index ba777db..67c6f0c 100644
--- a/src/modules/dashboard/views/DashboardView.test.mjs
+++ b/src/modules/dashboard/views/DashboardView.test.mjs
@@ -25,12 +25,15 @@ test('isolates recoverable dashboard failures without hiding successful sections
assert.doesNotMatch(source, /\s*/)
})
-test('matches the prototype business overview while keeping real order routing and data', () => {
+test('matches the prototype business overview and routes each card to its real business page', () => {
assert.match(source, /选择业务,快速下单/)
assert.match(source, /海外采购/)
assert.match(source, /美国本土卡/)
assert.match(source, /美本土散板/)
- assert.match(source, /void router\.push\('\/orders\/first-leg\/create'\)/)
+ assert.match(source, /route: '\/cross-border\/procurement'/)
+ assert.match(source, /route: '\/cross-border\/domestic-trucking'/)
+ assert.match(source, /route: '\/cross-border\/pallet-shipping'/)
+ assert.match(source, /auth\.hasPermission\(business\.permission\)/)
assert.match(source, /order\.status === 'DELIVERED'/)
assert.match(source, /currentMonthOrderCount/)
assert.match(source, /提交订单后,专属客户经理将在 24 小时内与您联系/)
diff --git a/src/modules/dashboard/views/DashboardView.vue b/src/modules/dashboard/views/DashboardView.vue
index ad0eb72..3803c16 100644
--- a/src/modules/dashboard/views/DashboardView.vue
+++ b/src/modules/dashboard/views/DashboardView.vue
@@ -40,7 +40,6 @@ const loadErrorItems = computed(() =>
})),
)
const canViewOrders = computed(() => auth.hasPermission('order:first-leg:list'))
-const canCreateOrders = computed(() => auth.hasPermission('order:first-leg:create'))
const accountName = computed(() => {
if (!companyUnavailable.value && tenant.company.companyName) return tenant.company.companyName
return auth.user?.tenantName || auth.user?.name || '企业客户'
@@ -67,18 +66,24 @@ const businessCards = [
description: '采购代购 · 验货质检 · 集中发货',
icon: ShoppingCart,
tone: 'blue',
+ route: '/cross-border/procurement',
+ permission: 'merchant:cross-border:procurement',
},
{
title: '美国本土卡',
description: '港口提柜 · 仓到仓 · 仓到门',
icon: Truck,
tone: 'green',
+ route: '/cross-border/domestic-trucking',
+ permission: 'merchant:cross-border:domestic-trucking',
},
{
title: '美本土散板',
description: '拼车拼载 · 打盘服务 · 灵活派送',
icon: PanelsTopLeft,
tone: 'teal',
+ route: '/cross-border/pallet-shipping',
+ permission: 'merchant:cross-border:pallet-shipping',
},
] as const
@@ -111,16 +116,12 @@ async function loadDashboardData() {
}
}
-function openBusinessOrder() {
- if (canCreateOrders.value) {
- void router.push('/orders/first-leg/create')
+function openBusinessOrder(business: typeof businessCards[number]) {
+ if (auth.hasPermission(business.permission)) {
+ void router.push(business.route)
return
}
- if (canViewOrders.value) {
- void router.push('/orders/first-leg')
- return
- }
- ElMessage.warning('当前账号暂无头程订单权限,请联系企业管理员。')
+ ElMessage.warning(`当前账号暂无${business.title}权限,请联系企业管理员。`)
}
function showSupportMessage(type: 'service' | 'faq' | 'quote') {
@@ -164,7 +165,7 @@ onMounted(() => void loadDashboardData())
type="button"
class="business-card"
:class="`business-card--${business.tone}`"
- @click="openBusinessOrder"
+ @click="openBusinessOrder(business)"
>
@@ -174,7 +175,7 @@ onMounted(() => void loadDashboardData())
{{ business.description }}
- {{ canCreateOrders ? '开始下单' : canViewOrders ? '查看订单' : '暂无权限' }}
+ {{ auth.hasPermission(business.permission) ? '开始下单' : '暂无权限' }}
diff --git a/src/modules/order-management/api/inboundPlans.ts b/src/modules/order-management/api/inboundPlans.ts
new file mode 100644
index 0000000..d62dfae
--- /dev/null
+++ b/src/modules/order-management/api/inboundPlans.ts
@@ -0,0 +1,73 @@
+import { overseasApiRequest } from '@/api/overseasHttp'
+
+export interface PageResult { records: T[]; page: number; size: number; total: number; pages: number }
+export interface InboundPlanQuery {
+ page: number
+ size: number
+ planNo?: string
+ inboundCode?: string
+ status?: string
+ destinationWarehouseCode?: string
+ appointmentStatus?: string
+ dispatchStatus?: string
+ createdFrom?: string
+ createdTo?: string
+}
+export interface InboundPlanSummary {
+ planNo: string
+ inboundCode: string
+ orderType: string | null
+ warehouseCode: string | null
+ warehouseName: string | null
+ fbaWarehouseCode: string | null
+ packageCount: number
+ pickedPackageCount: number
+ volumeCbm: number | null
+ palletCount: number
+ status: string
+ appointmentNo: string | null
+ appointmentStatus: string | null
+ appointmentAt: string | null
+ expectedLoadingAt: string | null
+ dispatchStatus: string | null
+ createdAt: string
+ version: number
+}
+export interface InboundPlanLine {
+ inboundCode: string
+ fbaWarehouseCode: string | null
+ destinationWarehouseCode: string | null
+ destinationWarehouseName: string | null
+ volumeCbm: number
+ requiredPackageCount: number
+ plannedPalletCount: number
+}
+export interface InboundPlanPallet {
+ palletNo: string
+ destinationWarehouseCode: string | null
+ destinationWarehouseName: string | null
+ capacityCbm: number
+ allocatedVolumeCbm: number
+}
+export interface InboundPlanDetail {
+ planNo: string
+ containerNo: string | null
+ status: string
+ lineCount: number
+ palletCount: number
+ totalVolumeCbm: number
+ expectedPackageCount: number
+ pickedPackageCount: number
+ lines: InboundPlanLine[]
+ pallets: InboundPlanPallet[]
+ version: number
+}
+
+export async function listInboundPlans(params: InboundPlanQuery) {
+ const result = await overseasApiRequest>({ method: 'GET', url: '/merchant/v1/last-leg/inbound-plans', params })
+ return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) }
+}
+
+export function getInboundPlan(planNo: string) {
+ return overseasApiRequest({ method: 'GET', url: `/merchant/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}` })
+}
diff --git a/src/modules/order-management/inboundOrders.test.mjs b/src/modules/order-management/inboundOrders.test.mjs
new file mode 100644
index 0000000..a9036aa
--- /dev/null
+++ b/src/modules/order-management/inboundOrders.test.mjs
@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+const api = readFileSync(new URL('./api/inboundPlans.ts', import.meta.url), 'utf8')
+const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
+const view = readFileSync(new URL('./views/InboundOrdersView.vue', import.meta.url), 'utf8')
+
+test('activates the customer-scoped overseas inbound list and detail', () => {
+ assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/)
+ assert.match(api, /\/merchant\/v1\/last-leg\/inbound-plans/)
+ assert.match(api, /encodeURIComponent\(planNo\)/)
+ assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/)
+})
+
+test('keeps actions first and exposes filters loading errors empty state pagination and business identifiers', () => {
+ assert.match(view, /]*fixed="left"/)
+ for (const token of ['planNo', 'inboundCode', 'destinationWarehouseCode', 'appointmentStatus', 'dispatchStatus', 'createdDates']) assert.match(view, new RegExp(token))
+ assert.match(view, /v-loading="loading"/)
+ assert.match(view, /type="error"/)
+ assert.match(view, /empty-text="暂无入库订单"/)
+ assert.match(view, / import('./views/InboundOrdersView.vue'),
- release: 'prototype',
+ release: 'active',
meta: { title: '入库订单列表', description: '查询海外仓入库订单', permission: 'merchant:warehouse:inbound:list' },
navigation: { key: 'orders-inbound', icon: Package },
},
diff --git a/src/modules/order-management/views/InboundOrdersView.vue b/src/modules/order-management/views/InboundOrdersView.vue
index 7134fee..cb0d25d 100644
--- a/src/modules/order-management/views/InboundOrdersView.vue
+++ b/src/modules/order-management/views/InboundOrdersView.vue
@@ -1,5 +1,137 @@
-
+
+
+
+ {{ detail ? '入库订单详情' : '入库订单列表' }}
{{ detail ? `计划号:${detail.planNo}` : '查询当前企业在海外仓的入库计划与作业进度' }}
+ 返回列表
+
+
+
+ 重新加载
+
+
+
+
+
+
+
+
+
+ 查询重置
+
+
+
+
+ 查看
+
+
+ {{ text(scope.row.warehouseName || scope.row.warehouseCode) }}
+ {{ text(scope.row.fbaWarehouseCode) }}
+
+
+
+ {{ statusLabel(scope.row.status) }}
+ {{ statusLabel(scope.row.appointmentStatus) }}
+ {{ statusLabel(scope.row.dispatchStatus) }}
+ {{ dateTime(scope.row.createdAt) }}
+
+
+
+
+
+
+
+
+ - 计划号
- {{ detail.planNo }}
- 集装箱号
- {{ text(detail.containerNo) }}
+ - 状态
- {{ statusLabel(detail.status) }}
- 入仓码数
- {{ detail.lineCount }}
+ - 托盘数
- {{ detail.palletCount }}
- 总体积(m³)
- {{ detail.totalVolumeCbm }}
+ - 计划箱数
- {{ detail.expectedPackageCount }}
- 已拣箱数
- {{ detail.pickedPackageCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs
index 0e5f74e..7029442 100644
--- a/src/navigation/clientNavigation.test.mjs
+++ b/src/navigation/clientNavigation.test.mjs
@@ -53,13 +53,17 @@ test('keeps only implemented navigation groups visible', () => {
title: '头程物流',
children: ['订单列表', '快速下单'],
},
+ {
+ title: '出海下单',
+ children: ['海外采购', '美国本土卡', '美本土散板'],
+ },
{
title: '海外仓',
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'],
},
{
title: '订单管理',
- children: ['派送订单列表'],
+ children: ['入库订单列表', '派送订单列表'],
},
{
title: '财务结算',
@@ -95,10 +99,14 @@ test('flattens visible leaves with unique paths', () => {
'/',
'/orders/first-leg',
'/first-leg/quick-order',
+ '/cross-border/procurement',
+ '/cross-border/domestic-trucking',
+ '/cross-border/pallet-shipping',
'/overseas-warehouse/labeling',
'/overseas-warehouse/storage-transfer',
'/overseas-warehouse/cargo-disposal',
'/overseas-warehouse/return-relabel',
+ '/orders/inbound',
'/orders/delivery',
'/finance/bills',
'/company/profile',
@@ -317,7 +325,7 @@ test('keeps the dashboard route permission synchronized with navigation', () =>
assert.match(dashboardRoute, /permission: 'dashboard:view'/)
})
-test('keeps prototype views cataloged while allowing implemented order and overseas warehouse pages', () => {
+test('keeps only the unified my-orders placeholder cataloged while activating implemented business pages', () => {
const routerSource = [
'first-leg',
'cross-border',
@@ -339,7 +347,7 @@ test('keeps prototype views cataloged while allowing implemented order and overs
]
for (const [path, view] of routes) {
- if (path === 'first-leg/quick-order' || path.startsWith('overseas-warehouse/') || path === 'orders/delivery') {
+ if (path === 'first-leg/quick-order' || path.startsWith('cross-border/') || path.startsWith('overseas-warehouse/') || path === 'orders/inbound' || path === 'orders/delivery') {
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`))
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true)
continue
From 6998f81738238244bb828d8ede8db770910ca4f5 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 4 Sep 2026 13:23:57 +0800
Subject: [PATCH 26/33] fix: expose overseas customer permissions
---
src/modules/tenant-management/api/merchantIam.test.mjs | 8 ++++++++
src/modules/tenant-management/contracts.ts | 4 ++++
2 files changed, 12 insertions(+)
diff --git a/src/modules/tenant-management/api/merchantIam.test.mjs b/src/modules/tenant-management/api/merchantIam.test.mjs
index bb0cb5f..68b133e 100644
--- a/src/modules/tenant-management/api/merchantIam.test.mjs
+++ b/src/modules/tenant-management/api/merchantIam.test.mjs
@@ -91,6 +91,14 @@ test('shares the merchant permission map and preserves resources not rendered by
assert.match(contractsSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/)
assert.match(contractsSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/)
assert.match(contractsSource, /'company:member:create': \['merchant:member:create'\]/)
+ for (const permission of [
+ 'merchant:warehouse:inbound:list',
+ 'merchant:cross-border:procurement',
+ 'merchant:cross-border:domestic-trucking',
+ 'merchant:cross-border:pallet-shipping',
+ ]) {
+ assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`))
+ }
assert.match(contractsSource, /'company:member:edit': \['merchant:member:edit'\]/)
assert.match(contractsSource, /'company:member:grant-role': \['merchant:member:grant-role'\]/)
assert.match(contractsSource, /'company:member:status': \['merchant:member:status'\]/)
diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts
index 0884cdf..4930f94 100644
--- a/src/modules/tenant-management/contracts.ts
+++ b/src/modules/tenant-management/contracts.ts
@@ -101,6 +101,10 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly
Date: Fri, 4 Sep 2026 14:41:27 +0800
Subject: [PATCH 27/33] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index e36059e..b27271c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,7 +47,7 @@
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换 |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
-| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,入库费用 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。客户确认当前 `SENT` 费用时二次确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
+| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,独立计费重弹框 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。所有订单行均可进入计费重弹框,尚未推送时展示明确空态;只有具备编辑权限且服务端返回当前 `SENT` 费用时才可二次确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
| 装柜完成后的退税报关资料 | `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` 和幂等键 |
From dbd3c61996a1bcb4eef7bd5946f8750fb9b43ac7 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 4 Sep 2026 16:28:44 +0800
Subject: [PATCH 28/33] docs: refresh client workflow contracts
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 30bcaf3..4293263 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,7 @@
| 业务 | 模块入口 | 联查模块或服务端 |
|---|---|---|
| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常旋转的国内/海外 Token 会话统一持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token |
-| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换 |
+| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换。前端角色树到服务端权限的映射统一维护在 `contracts.ts` 的 `FRONTEND_TO_MERCHANT_PERMISSION_CODES`,海外入仓、采购、本土卡车和散板权限必须显式进入该映射 |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,独立计费重弹框 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。所有订单行均可进入计费重弹框,尚未推送时展示明确空态;只有具备编辑权限且服务端返回当前 `SENT` 费用时才可二次确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
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 29/33] 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 @@
+
+
+
+
+
+
资金账户
查看本企业 CNY 资金余额、账户流水和充值申请状态。
+
账户充值
+
+
+
+
+
+
+ 实际余额¥ {{ formatAmount(account?.actualBalance) }}当前账户实际资金
+ 冻结金额¥ {{ formatAmount(account?.frozenBalance) }}暂不可使用的资金
+ 可用余额¥ {{ formatAmount(account?.availableBalance) }}可用于后续业务支付
+
+
+
+
+
+
+ {{ changeLabels[row.changeType as AccountChangeType] }}
+ {{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}
+ ¥ {{ formatAmount(row.actualBalanceAfter) }}
+ ¥ {{ formatAmount(row.frozenBalanceAfter) }}
+ ¥ {{ formatAmount(row.availableBalanceAfter) }}
+ {{ row.operatorName || '—' }}
+ {{ row.remark || '—' }}
+ {{ formatDateTime(row.createdAt) }}
+
+
+
+
+
+
+
+
+ ¥ {{ formatAmount(row.amount) }}
+ {{ methodLabels[row.paymentMethod as RechargePaymentMethod] }}
+
+ {{ statusLabels[row.status as RechargeStatus] }}
+ {{ row.submittedByName || '—' }}
+ {{ row.reviewRemark || '—' }}
+ {{ formatDateTime(row.createdAt) }}
+ {{ formatDateTime(row.reviewedAt) }}
+
+
+
+
+
+
+
+ 取消确认充值
+
+
+
+
+
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',
From 719a6421c9d1d9f5b273c6ac12448d59dd3699e0 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 5 Sep 2026 08:41:32 +0800
Subject: [PATCH 30/33] chore(finance): add development recharge sample
---
public/customer-recharge-qr.svg | 12 ++++++++++++
1 file changed, 12 insertions(+)
create mode 100644 public/customer-recharge-qr.svg
diff --git a/public/customer-recharge-qr.svg b/public/customer-recharge-qr.svg
new file mode 100644
index 0000000..2e087a1
--- /dev/null
+++ b/public/customer-recharge-qr.svg
@@ -0,0 +1,12 @@
+
From 46a91fbca05ea7a64ac57dd31ac4539355c64be7 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 5 Sep 2026 11:54:55 +0800
Subject: [PATCH 31/33] feat(finance): show customer credit balances
---
src/modules/finance/api/account.ts | 11 +++++++++--
src/modules/finance/views/FinanceAccountView.test.mjs | 5 ++++-
src/modules/finance/views/FinanceAccountView.vue | 11 ++++++++---
3 files changed, 21 insertions(+), 6 deletions(-)
diff --git a/src/modules/finance/api/account.ts b/src/modules/finance/api/account.ts
index 239a465..1928d09 100644
--- a/src/modules/finance/api/account.ts
+++ b/src/modules/finance/api/account.ts
@@ -13,11 +13,15 @@ export interface FinanceAccount {
actualBalance: string
frozenBalance: string
availableBalance: string
+ creditLimit: string
+ creditUsed: string
+ creditFrozen: string
+ availableCredit: string
version: number
updatedAt: string
}
-export type AccountChangeType = 'RECHARGE' | 'DEDUCTION' | 'FREEZE' | 'UNFREEZE' | 'CONSUMPTION'
+export type AccountChangeType = 'RECHARGE' | 'DEDUCTION' | 'FREEZE' | 'UNFREEZE' | 'CONSUMPTION' | 'CREDIT_LIMIT'
export interface AccountLedgerEntry {
ledgerId: string
@@ -27,7 +31,10 @@ export interface AccountLedgerEntry {
actualBalanceAfter: string
frozenBalanceAfter: string
availableBalanceAfter: string
- sourceType: 'CLIENT_RECHARGE' | 'MANUAL_RECHARGE' | 'MANUAL_DEDUCTION'
+ creditLimitAfter: string
+ creditUsedAfter: string
+ availableCreditAfter: string
+ sourceType: 'CLIENT_RECHARGE' | 'MANUAL_RECHARGE' | 'MANUAL_DEDUCTION' | 'CREDIT_LIMIT_ADJUSTMENT' | string
sourceNo: string
operatorName: string | null
remark: string | null
diff --git a/src/modules/finance/views/FinanceAccountView.test.mjs b/src/modules/finance/views/FinanceAccountView.test.mjs
index 38c91d5..dcaa9c7 100644
--- a/src/modules/finance/views/FinanceAccountView.test.mjs
+++ b/src/modules/finance/views/FinanceAccountView.test.mjs
@@ -27,7 +27,10 @@ test('activates the account route and maps read and recharge permissions indepen
})
test('renders server balances, ledger and recharge states without internal identifiers', () => {
- for (const field of ['actualBalance', 'frozenBalance', 'availableBalance']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
+ for (const field of ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
+ for (const field of ['creditUsedAfter', 'availableCreditAfter']) assert.match(viewSource, new RegExp(`row\\.${field}`))
+ assert.match(viewSource, /实际可用资金与可用授信合计/)
+ assert.match(viewSource, /CREDIT_LIMIT: '授信调整'/)
assert.match(viewSource, /row-key="businessNo"/)
assert.match(viewSource, /row-key="requestNo"/)
assert.match(viewSource, /empty-text="暂无资金流水"/)
diff --git a/src/modules/finance/views/FinanceAccountView.vue b/src/modules/finance/views/FinanceAccountView.vue
index 14a0585..43f94be 100644
--- a/src/modules/finance/views/FinanceAccountView.vue
+++ b/src/modules/finance/views/FinanceAccountView.vue
@@ -51,7 +51,7 @@ const amountValid = computed(() => {
const canSubmit = computed(() => amountValid.value && channelReady.value && Boolean(voucherFile.value) && !submitting.value)
const changeLabels: Record = {
- RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费',
+ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费', CREDIT_LIMIT: '授信调整',
}
const statusLabels: Record = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
const methodLabels: Record = { WECHAT: '微信支付', ALIPAY: '支付宝' }
@@ -208,7 +208,7 @@ onMounted(() => { void loadAccountData() })
-
资金账户
查看本企业 CNY 资金余额、账户流水和充值申请状态。
+
资金账户
查看本企业 CNY 资金、授信额度、账户流水和充值申请状态。
账户充值
@@ -220,7 +220,10 @@ onMounted(() => { void loadAccountData() })
实际余额¥ {{ formatAmount(account?.actualBalance) }}当前账户实际资金
冻结金额¥ {{ formatAmount(account?.frozenBalance) }}暂不可使用的资金
- 可用余额¥ {{ formatAmount(account?.availableBalance) }}可用于后续业务支付
+ 总可用余额¥ {{ formatAmount(account?.availableBalance) }}实际可用资金与可用授信合计
+ 授信额度¥ {{ formatAmount(account?.creditLimit) }}平台配置的授信上限
+ 已用授信¥ {{ formatAmount(account?.creditUsed) }}已结算使用的授信
+ 可用授信¥ {{ formatAmount(account?.availableCredit) }}扣除已用和冻结后的授信
@@ -232,6 +235,8 @@ onMounted(() => { void loadAccountData() })
¥ {{ formatAmount(row.actualBalanceAfter) }}
¥ {{ formatAmount(row.frozenBalanceAfter) }}
¥ {{ formatAmount(row.availableBalanceAfter) }}
+ ¥ {{ formatAmount(row.creditUsedAfter) }}
+ ¥ {{ formatAmount(row.availableCreditAfter) }}
{{ row.operatorName || '—' }}
{{ row.remark || '—' }}
{{ formatDateTime(row.createdAt) }}
From 85871d5c2ce269e8ae67ac9da22b2405dfcfebd0 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 5 Sep 2026 12:42:59 +0800
Subject: [PATCH 32/33] fix(finance): avoid showing zero before balance loads
---
.../finance/views/FinanceAccountView.test.mjs | 2 ++
src/modules/finance/views/FinanceAccountView.vue | 16 ++++++++++------
2 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/src/modules/finance/views/FinanceAccountView.test.mjs b/src/modules/finance/views/FinanceAccountView.test.mjs
index dcaa9c7..7c1dbbc 100644
--- a/src/modules/finance/views/FinanceAccountView.test.mjs
+++ b/src/modules/finance/views/FinanceAccountView.test.mjs
@@ -37,6 +37,8 @@ test('renders server balances, ledger and recharge states without internal ident
assert.match(viewSource, /empty-text="暂无充值申请"/)
assert.match(viewSource, /account-state--error/)
assert.match(viewSource, /v-loading="pageLoading"/)
+ assert.match(viewSource, /function formatAccountAmount[\s\S]*value == null \? '—' : formatAmount\(value\)/)
+ assert.match(viewSource, /formatAccountAmount\(account\?\.actualBalance\)/)
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
})
diff --git a/src/modules/finance/views/FinanceAccountView.vue b/src/modules/finance/views/FinanceAccountView.vue
index 43f94be..680b515 100644
--- a/src/modules/finance/views/FinanceAccountView.vue
+++ b/src/modules/finance/views/FinanceAccountView.vue
@@ -67,6 +67,10 @@ function formatAmount(value: string | null | undefined) {
: '0.00'
}
+function formatAccountAmount(value: string | null | undefined) {
+ return value == null ? '—' : formatAmount(value)
+}
+
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
}
@@ -218,12 +222,12 @@ onMounted(() => { void loadAccountData() })
- 实际余额¥ {{ formatAmount(account?.actualBalance) }}当前账户实际资金
- 冻结金额¥ {{ formatAmount(account?.frozenBalance) }}暂不可使用的资金
- 总可用余额¥ {{ formatAmount(account?.availableBalance) }}实际可用资金与可用授信合计
- 授信额度¥ {{ formatAmount(account?.creditLimit) }}平台配置的授信上限
- 已用授信¥ {{ formatAmount(account?.creditUsed) }}已结算使用的授信
- 可用授信¥ {{ formatAmount(account?.availableCredit) }}扣除已用和冻结后的授信
+ 实际余额¥ {{ formatAccountAmount(account?.actualBalance) }}当前账户实际资金
+ 冻结金额¥ {{ formatAccountAmount(account?.frozenBalance) }}暂不可使用的资金
+ 总可用余额¥ {{ formatAccountAmount(account?.availableBalance) }}实际可用资金与可用授信合计
+ 授信额度¥ {{ formatAccountAmount(account?.creditLimit) }}平台配置的授信上限
+ 已用授信¥ {{ formatAccountAmount(account?.creditUsed) }}已结算使用的授信
+ 可用授信¥ {{ formatAccountAmount(account?.availableCredit) }}扣除已用和冻结后的授信
From b13295f29221539f5f677c8fd5041976a1709b1f Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 5 Sep 2026 22:26:37 +0800
Subject: [PATCH 33/33] feat: submit procurement identity and relabel charge
---
src/modules/cross-border/api/businessOrders.ts | 1 +
.../cross-border/components/BusinessOrdersView.vue | 3 +++
.../overseas-warehouse/api/warehouseServices.ts | 2 +-
.../overseas-warehouse/views/WarehouseServiceView.vue | 11 ++++++-----
4 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts
index 3a355f8..91db22d 100644
--- a/src/modules/cross-border/api/businessOrders.ts
+++ b/src/modules/cross-border/api/businessOrders.ts
@@ -59,6 +59,7 @@ export interface ProcurementDetail {
}
export interface ProcurementCreate {
+ customerName: string
purchaseCategory: string
productName: string
productSpecification?: string
diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue
index 1969379..a2c6e77 100644
--- a/src/modules/cross-border/components/BusinessOrdersView.vue
+++ b/src/modules/cross-border/components/BusinessOrdersView.vue
@@ -1,6 +1,7 @@