From a8590db98b593241ee2e4d28eef785c6c034cde3 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sun, 9 Aug 2026 16:13:42 +0800 Subject: [PATCH] feat(bx-mobile): add invoice wallet pages and bind reimburse apply to balance rules Co-authored-by: Cursor --- bx-mobile/src/api/index.ts | 18 +- bx-mobile/src/constants/bxMenuCatalog.ts | 2 +- bx-mobile/src/router/index.ts | 14 +- .../views/invoice/InvoiceWalletAdminPage.vue | 267 ++++++++++++++ .../views/invoice/InvoiceWalletMinePage.vue | 239 +++++++++++++ .../views/reimburse/ReimburseApplyPage.vue | 326 +++++++++++------- docs/business-inventory/03-报销业务明细.md | 2 +- 7 files changed, 745 insertions(+), 123 deletions(-) create mode 100644 bx-mobile/src/views/invoice/InvoiceWalletAdminPage.vue create mode 100644 bx-mobile/src/views/invoice/InvoiceWalletMinePage.vue diff --git a/bx-mobile/src/api/index.ts b/bx-mobile/src/api/index.ts index 1c11f8eb9..14a463c5b 100644 --- a/bx-mobile/src/api/index.ts +++ b/bx-mobile/src/api/index.ts @@ -54,7 +54,8 @@ export const reimburseApi = { getApprovals: (id: BxId) => bxClient.get(`/reimburses/${id}/approvals`), create: (data: Record) => bxClient.post('/reimburses/', data), update: (id: BxId, data: Record) => bxClient.put(`/reimburses/${id}`, data), - submit: (id: BxId) => bxClient.post(`/reimburses/${id}/submit`), + submit: (id: BxId, data?: Record) => + bxClient.post(`/reimburses/${id}/submit`, data), approve: (id: BxId, data: { approvalOpinion?: string }) => bxClient.post(`/reimburses/${id}/approve`, data), reject: (id: BxId, data: { approvalOpinion?: string }) => @@ -62,6 +63,21 @@ export const reimburseApi = { delete: (id: BxId) => bxClient.delete(`/reimburses/${id}`), } +export const invoiceWalletApi = { + mineSummary: (params?: { userId?: BxId }) => + bxClient.get('/invoice-wallets/mine/summary', { params }), + list: (params: { page: number; pageSize: number; keyword?: string }) => + bxClient.get('/invoice-wallets', { params }), +} + +export const invoiceApi = { + list: (params?: { page?: number; pageSize?: number; userId?: BxId }) => + bxClient.get('/invoices', { params }), + upload: (formData: FormData) => bxClient.post('/invoices', formData), + delete: (id: BxId) => bxClient.delete(`/invoices/${id}`), + ledgers: (params?: { userId?: BxId }) => bxClient.get('/invoice-ledgers', { params }), +} + export const paymentApi = { pending: (params?: Record) => bxClient.get('/payments/pending', { params }), pay: (id: BxId, data?: { payRemark?: string; actualAmount?: number }) => diff --git a/bx-mobile/src/constants/bxMenuCatalog.ts b/bx-mobile/src/constants/bxMenuCatalog.ts index 27ebc90ef..4d9f0a91d 100644 --- a/bx-mobile/src/constants/bxMenuCatalog.ts +++ b/bx-mobile/src/constants/bxMenuCatalog.ts @@ -141,4 +141,4 @@ export const BX_MENU_CATALOG: BxMenuEntry[] = [ ] /** 当前已开放阶段:发版时改为 1 → 2 → 3 */ -export const BX_MENU_CURRENT_PHASE: BxMenuPhase = 1 +export const BX_MENU_CURRENT_PHASE: BxMenuPhase = 2 diff --git a/bx-mobile/src/router/index.ts b/bx-mobile/src/router/index.ts index a7138ab0b..414157352 100644 --- a/bx-mobile/src/router/index.ts +++ b/bx-mobile/src/router/index.ts @@ -117,7 +117,19 @@ const routes = [ component: () => import('@/views/all/AllPage.vue'), meta: { menuCodes: ['bx:viewAll:list'] }, }, - /* P1:不注册 invoice/*、admin 配置与 IAM(users/roles/menus) */ + { + path: 'invoice/mine', + name: 'InvoiceWalletMine', + component: () => import('@/views/invoice/InvoiceWalletMinePage.vue'), + meta: { menuCodes: ['bx:invoiceWallet:mine'] }, + }, + { + path: 'invoice/admin', + name: 'InvoiceWalletAdmin', + component: () => import('@/views/invoice/InvoiceWalletAdminPage.vue'), + meta: { menuCodes: ['bx:invoiceWallet:admin'] }, + }, + /* P3 再注册 admin 配置;IAM(users/roles/menus)已移除 */ { path: 'profile', name: 'Profile', component: () => import('@/views/profile/ProfilePage.vue') }, ], }, diff --git a/bx-mobile/src/views/invoice/InvoiceWalletAdminPage.vue b/bx-mobile/src/views/invoice/InvoiceWalletAdminPage.vue new file mode 100644 index 000000000..aff6f4dff --- /dev/null +++ b/bx-mobile/src/views/invoice/InvoiceWalletAdminPage.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/bx-mobile/src/views/invoice/InvoiceWalletMinePage.vue b/bx-mobile/src/views/invoice/InvoiceWalletMinePage.vue new file mode 100644 index 000000000..33be6422f --- /dev/null +++ b/bx-mobile/src/views/invoice/InvoiceWalletMinePage.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/bx-mobile/src/views/reimburse/ReimburseApplyPage.vue b/bx-mobile/src/views/reimburse/ReimburseApplyPage.vue index 9ffd3964f..c796f5868 100644 --- a/bx-mobile/src/views/reimburse/ReimburseApplyPage.vue +++ b/bx-mobile/src/views/reimburse/ReimburseApplyPage.vue @@ -93,47 +93,60 @@ - +
-

附件

+

关联发票(可选)

-
- - - -
-
+

+ 发票余额 ¥{{ money(walletBalance) }} · 本次合计 + ¥{{ detailTotalAmount }} + (余额不足,无法提交) +

+ +
- -
- {{ attachment.fileExt(file.fileName) }} - {{ file.fileName }} -
- + ¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }} +
+

请先在「我的发票夹」上传发票;提交时按合计扣减余额。

+ +
+
选择发票
+ + + + + + + + + +
+
+
@@ -228,58 +241,19 @@ import { ref, reactive, computed, watch, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { showToast, showDialog } from 'vant' -import { reimburseApi } from '@/api' +import { invoiceApi, invoiceWalletApi, reimburseApi } from '@/api' import { BizError } from '@/api/client' import { useExpenseItemTree, formatExpenseName, type ExpenseItem, } from '@/hooks/useExpenseItemTree' -import { useAttachment } from '@/hooks/useAttachment' import { SUBMIT_APPROVE_BUTTON_COLOR } from '@/constants/dialog' import BottomActionBar from '@/components/mobile/BottomActionBar.vue' -const MAX_ATTACHMENTS = 10 - const router = useRouter() const route = useRoute() -/** 与备用金申请附件 accept 一致 */ -const attachmentAccept = - [ - 'image/*', - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.ms-powerpoint', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'text/plain', - 'text/csv', - 'application/rtf', - 'application/zip', - 'application/x-zip-compressed', - 'application/x-rar-compressed', - 'application/x-7z-compressed', - '.pdf', - '.doc', - '.docx', - '.xls', - '.xlsx', - '.ppt', - '.pptx', - '.txt', - '.csv', - '.rtf', - '.wps', - '.et', - '.dps', - '.zip', - '.rar', - '.7z', - ].join(',') - interface DetailLine { expenseItemId: number | string amount: string @@ -299,12 +273,80 @@ const pickingLineIndex = ref(0) const selectedLeftId = ref(null) const { expenseTree, flatList, load: loadExpenseItems } = useExpenseItemTree() -const attachment = useAttachment() -const detailTotalAmount = computed(() => { - const total = form.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0) - return total.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) -}) +const walletBalance = ref(0) +const walletInvoices = ref([]) +const selectedInvoiceIds = ref([]) +const showInvoicePicker = ref(false) +const pickerCheckedIds = ref([]) + +const detailTotalNumber = computed(() => + form.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0), +) + +const detailTotalAmount = computed(() => + detailTotalNumber.value.toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), +) + +const balanceInsufficient = computed( + () => detailTotalNumber.value > 0 && walletBalance.value < detailTotalNumber.value, +) + +const selectedInvoiceRows = computed(() => + walletInvoices.value.filter((inv) => selectedInvoiceIds.value.includes(String(inv.id))), +) + +function money(v: unknown) { + return Number(v || 0).toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} + +async function refreshWallet() { + try { + const [sumRes, invRes]: any[] = await Promise.all([ + invoiceWalletApi.mineSummary(), + invoiceApi.list({ page: 1, pageSize: 200 }), + ]) + walletBalance.value = Number(sumRes?.data?.balance ?? 0) + const d = invRes?.data + walletInvoices.value = d?.rows ?? d?.items ?? d?.list ?? (Array.isArray(d) ? d : []) + } catch { + /* ignore */ + } +} + +function openInvoicePicker() { + pickerCheckedIds.value = [...selectedInvoiceIds.value] + showInvoicePicker.value = true +} + +function togglePickerId(id: string | number) { + const sid = String(id) + const i = pickerCheckedIds.value.indexOf(sid) + if (i >= 0) pickerCheckedIds.value.splice(i, 1) + else pickerCheckedIds.value.push(sid) +} + +function confirmInvoicePicker() { + selectedInvoiceIds.value = [...pickerCheckedIds.value] + showInvoicePicker.value = false +} + +function removeInvoice(id: string | number) { + const sid = String(id) + selectedInvoiceIds.value = selectedInvoiceIds.value.filter((x) => x !== sid) +} + +function buildInvoicePayload() { + const invoiceIds = [...selectedInvoiceIds.value] + const invoiceLinks = invoiceIds.map((invoiceId) => ({ invoiceId })) + return { invoiceIds, invoiceLinks } +} function lineExpenseDisplay(id: number | string): string { if (id === '' || id == null) return '' @@ -393,28 +435,6 @@ function removeDetailLine(index: number) { form.details.splice(index, 1) } -function beforeReadAttachment(file: File | File[]) { - const batch = Array.isArray(file) ? file : [file] - const remain = MAX_ATTACHMENTS - attachment.files.value.length - if (remain <= 0) { - showToast(`最多上传 ${MAX_ATTACHMENTS} 个附件`) - return false - } - if (batch.length > remain) { - showToast(`还可上传 ${remain} 个附件,超出部分已忽略`) - return Promise.resolve(batch.slice(0, remain)) - } - return true -} - -async function onAfterRead(uploadFile: any) { - const files = Array.isArray(uploadFile) ? uploadFile : [uploadFile] - for (const f of files) { - if (!f?.file) continue - await attachment.upload(f.file, 'reimburse_apply', '') - } -} - /** 与明细行 * 一致:逐行校验,避免漏填仍被 filter 静默丢弃;失败返回 null */ function validateReimburseApplyForm(): DetailLine[] | null { for (let i = 0; i < form.details.length; i++) { @@ -461,6 +481,7 @@ async function onSubmit() { submitting.value = true try { + const invoicePayload = buildInvoicePayload() const payload = { title: form.title || undefined, remark: form.remark || undefined, @@ -469,6 +490,7 @@ async function onSubmit() { amount: Number(d.amount), remark: String(d.remark).trim(), })), + ...invoicePayload, } let id: string @@ -476,7 +498,6 @@ async function onSubmit() { if (editingId.value) { id = editingId.value await reimburseApi.update(id, payload) - await attachment.bindNew('reimburse_apply', id) showToast('保存成功') } else { const res: any = await reimburseApi.create(payload) @@ -484,31 +505,41 @@ async function onSubmit() { if (!id) { throw new Error('保存成功但未返回报销单ID') } - await attachment.bindNew('reimburse_apply', id) editingId.value = id showToast('保存成功') } showDialog({ title: isEditingAction ? '保存成功' : '创建成功', - message: '报销单已保存为草稿', + message: balanceInsufficient.value + ? '已保存为草稿。发票余额不足,请先充值发票夹后再提交。' + : '报销单已保存为草稿', showCancelButton: true, confirmButtonText: '提交审批', confirmButtonColor: SUBMIT_APPROVE_BUTTON_COLOR, cancelButtonText: '继续编辑', - }).then(() => { - // 用户点击"提交审批" - reimburseApi.submit(id).then(() => { - showToast('提交成功') - router.back() - }).catch((e: any) => { - if (!(e instanceof BizError)) { - showToast(e?.message || '提交失败') - } - }) - }).catch(() => { - // 用户点击"继续编辑" — 留在当前页 }) + .then(() => { + if (balanceInsufficient.value) { + showToast('余额不足,无法提交') + return + } + reimburseApi + .submit(id, invoicePayload) + .then(async () => { + showToast('提交成功') + await refreshWallet() + router.back() + }) + .catch((e: any) => { + if (!(e instanceof BizError)) { + showToast(e?.message || '提交失败') + } + }) + }) + .catch(() => { + /* 继续编辑 */ + }) } catch (e: any) { if (!(e instanceof BizError)) { showToast(e?.message || '保存失败') @@ -522,7 +553,7 @@ async function loadEditData() { const id = route.query.id if (!id) { editingId.value = null - attachment.clear() + selectedInvoiceIds.value = [] return } editingId.value = String(id) @@ -540,7 +571,11 @@ async function loadEditData() { if (form.details.length === 0) { form.details = [{ expenseItemId: '', amount: '', remark: '' }] } - await attachment.loadList('reimburse_apply', editingId.value) + const linked = data.invoices || [] + selectedInvoiceIds.value = linked.map((x: any) => String(x.id || x.invoiceId)).filter(Boolean) + if (data.status && data.status !== 'draft' && data.status !== 'rejected') { + showToast('旧草稿若仍含本地发票附件,请改走发票夹关联') + } } catch (e: any) { showToast(e?.message || '加载数据失败') } @@ -551,7 +586,7 @@ watch( async (id, prev) => { if (!id) { editingId.value = null - attachment.clear() + selectedInvoiceIds.value = [] return } if (String(id) === String(prev ?? '')) return @@ -561,7 +596,7 @@ watch( onMounted(async () => { sessionStorage.removeItem('bx_edit_resubmit') - await loadExpenseItems() + await Promise.all([loadExpenseItems(), refreshWallet()]) await loadEditData() if (!editingId.value && !form.title) { try { @@ -569,7 +604,9 @@ onMounted(async () => { if (res.data) { form.title = typeof res.data === 'string' ? res.data : res.data.title || '' } - } catch { /* skip */ } + } catch { + /* skip */ + } } }) @@ -615,6 +652,57 @@ onMounted(async () => { font-size: 16px; } +.invoice-balance-tip { + font-size: 13px; + color: var(--text-secondary); + margin: 0 0 10px; + line-height: 1.5; +} +.invoice-balance-tip--warn { + color: #dc2626; + margin-left: 4px; +} +.invoice-picked { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} +.invoice-picked__item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + background: #f8fafc; + border-radius: 8px; + font-size: 13px; +} +.invoice-picked__item .del { + border: none; + background: transparent; + font-size: 18px; + color: #94a3b8; +} +.form-tip { + margin-top: 8px; + font-size: 12px; + color: var(--text-muted); +} +.invoice-picker { + display: flex; + flex-direction: column; + height: 100%; + padding-bottom: env(safe-area-inset-bottom); +} +.invoice-picker__title { + padding: 14px 16px; + font-weight: 600; + text-align: center; +} +.invoice-picker__footer { + margin-top: auto; + padding: 12px 16px; +} .advance-attachment-upload { display: flex; justify-content: flex-start; diff --git a/docs/business-inventory/03-报销业务明细.md b/docs/business-inventory/03-报销业务明细.md index a15e15410..53a2eaf4c 100644 --- a/docs/business-inventory/03-报销业务明细.md +++ b/docs/business-inventory/03-报销业务明细.md @@ -282,7 +282,7 @@ | 项 | 说明 | |----|------| -| `bx-mobile` 移动端 | 未迁入门户 | +| `bx-mobile` 移动端 | H5 适配进行中:P1 门户鉴权+核心单据已对接;P2 发票夹已对齐 | | 历史数据迁移 | 方案外,空库启动 | | 原 bx 独立用户体系 | 已废弃 |