diff --git a/web-admin/src/api/bx/index.ts b/web-admin/src/api/bx/index.ts index de825c3b2..373b82659 100644 --- a/web-admin/src/api/bx/index.ts +++ b/web-admin/src/api/bx/index.ts @@ -171,14 +171,20 @@ export const reimburseApi = { getApprovalHistory(id: BxId) { return bxRequest.get(`/reimburses/${id}/approval-history`) }, + /** body 可含 invoiceIds: string[] 关联发票夹发票(草稿保存时同步关联) */ create(data: Record) { return bxRequest.post('/reimburses/', data) }, + /** body 可含 invoiceIds: string[] 关联发票夹发票(草稿保存时同步关联) */ update(id: BxId, data: Record) { return bxRequest.put(`/reimburses/${id}`, data) }, - submit(id: BxId) { - return bxRequest.post(`/reimburses/${id}/submit`) + /** body 可含 invoiceIds: string[] 提交时同步关联并扣减发票余额 */ + submit(id: BxId, data?: Record) { + return bxRequest.post(`/reimburses/${id}/submit`, data) + }, + withdraw(id: BxId) { + return bxRequest.post(`/reimburses/${id}/withdraw`) }, approve(id: BxId, data: { approvalOpinion?: string }) { return bxRequest.post(`/reimburses/${id}/approve`, data) @@ -276,6 +282,33 @@ export const paymentApi = { } } +export const invoiceWalletApi = { + mineSummary() { + return bxRequest.get('/invoice-wallets/mine/summary') + }, + list(params: { page: number; pageSize: number; keyword?: string }) { + return bxRequest.get('/invoice-wallets', { params }) + } +} + +export const invoiceApi = { + list(params: { page?: number; pageSize?: number; userId?: BxId }) { + return bxRequest.get('/invoices', { params }) + }, + /** FormData 字段:file, amount, remark?, targetUserId?(管理员代传) */ + upload(formData: FormData) { + return bxRequest.post('/invoices', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + delete(id: BxId) { + return bxRequest.delete(`/invoices/${id}`) + }, + ledgers(params?: { userId?: BxId }) { + return bxRequest.get('/invoice-ledgers', { params }) + } +} + export const attachmentApi = { upload(file: File, bizType: string = '', bizId: BxId = 0) { const formData = new FormData() diff --git a/web-admin/src/components/bx/base/BxInvoiceSelectDialog.vue b/web-admin/src/components/bx/base/BxInvoiceSelectDialog.vue new file mode 100644 index 000000000..ee9c82e6b --- /dev/null +++ b/web-admin/src/components/bx/base/BxInvoiceSelectDialog.vue @@ -0,0 +1,111 @@ + + + diff --git a/web-admin/src/router/modules/sqxp.ts b/web-admin/src/router/modules/sqxp.ts index 736265eb9..a3fed6e92 100644 --- a/web-admin/src/router/modules/sqxp.ts +++ b/web-admin/src/router/modules/sqxp.ts @@ -166,6 +166,28 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [ keepAlive: true, menuCode: 'bx:payment:list' } + }, + { + id: 'BX00000000000000000000000000020', + name: 'BxInvoiceWalletAdmin', + path: 'invoice-wallet-admin', + component: '/bx/invoice-wallet/admin/index', + meta: { + title: '发票夹管理', + keepAlive: true, + menuCode: 'bx:invoiceWallet:admin' + } + }, + { + id: 'BX00000000000000000000000000021', + name: 'BxInvoiceWalletMine', + path: 'invoice-wallet-mine', + component: '/bx/invoice-wallet/mine/index', + meta: { + title: '我的发票夹', + keepAlive: true, + menuCode: 'bx:invoiceWallet:mine' + } } ] }, diff --git a/web-admin/src/utils/router/menu-icon.ts b/web-admin/src/utils/router/menu-icon.ts index bb5933978..67b742bc6 100644 --- a/web-admin/src/utils/router/menu-icon.ts +++ b/web-admin/src/utils/router/menu-icon.ts @@ -99,6 +99,8 @@ export const SIDEBAR_MENU_ICON_BY_CODE: Record = { 'bx:reimburseApply:list': 'ri:file-edit-line', 'bx:reimburseApprove:list': 'ri:checkbox-circle-line', 'bx:payment:list': 'ri:bank-card-line', + 'bx:invoiceWallet:admin': 'ri:folder-shared-line', + 'bx:invoiceWallet:mine': 'ri:wallet-3-line', 'bx:viewMine:list': 'ri:user-line', 'bx:viewTodo:list': 'ri:time-line', 'bx:viewAll:list': 'ri:file-list-3-line', diff --git a/web-admin/src/views/bx/invoice-wallet/admin/index.vue b/web-admin/src/views/bx/invoice-wallet/admin/index.vue new file mode 100644 index 000000000..51ea29d61 --- /dev/null +++ b/web-admin/src/views/bx/invoice-wallet/admin/index.vue @@ -0,0 +1,378 @@ + + + + + + diff --git a/web-admin/src/views/bx/invoice-wallet/mine/index.vue b/web-admin/src/views/bx/invoice-wallet/mine/index.vue new file mode 100644 index 000000000..7829644c7 --- /dev/null +++ b/web-admin/src/views/bx/invoice-wallet/mine/index.vue @@ -0,0 +1,279 @@ + + + + + + diff --git a/web-admin/src/views/bx/reimburse/apply/index.vue b/web-admin/src/views/bx/reimburse/apply/index.vue index ddf7e4402..e99485cf7 100644 --- a/web-admin/src/views/bx/reimburse/apply/index.vue +++ b/web-admin/src/views/bx/reimburse/apply/index.vue @@ -328,13 +328,33 @@
-

附件上传

- +

关联发票(可选)

+

+ 当前发票余额:{{ formatMoneyYuan(walletBalance) }} + ;本次合计:{{ formatMoneyYuan(detailTotalAmount) }} + + (余额不足,提交时将被拦截) + +

+
+ + 从发票夹选择 + + 发票请先在「我的发票夹」上传;此处仅做可选关联。 +
+
+ + {{ formatMoneyYuan(inv.amount) }} 元 + + +
+
未关联发票(可选)
@@ -353,6 +373,12 @@ + + +
+

关联发票

+ + + + + + + + + + + +
+
-

附件

+

历史附件

@@ -499,9 +540,10 @@ import { Delete, Check, Search, - RefreshLeft + RefreshLeft, + FolderOpened } from '@element-plus/icons-vue' -import { reimburseApi, attachmentApi } from '@/api/bx' +import { reimburseApi, invoiceWalletApi } from '@/api/bx' import { buildApplyDateRangeQuery, type ApplyDateRange } from '@/utils/bx/apply-date-filter' import { canDeleteByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete' import { useExpenseItemTree, type ExpenseItem } from '@/hooks/bx/useExpenseItemTree' @@ -520,11 +562,13 @@ import { useServerPaged } from '@/hooks/bx/useServerPaged' import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue' import BxViewKvGrid from '@/components/bx/base/BxViewKvGrid.vue' import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue' -import BxAttachmentUpload from '@/components/bx/base/BxAttachmentUpload.vue' import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue' +import BxInvoiceSelectDialog from '@/components/bx/base/BxInvoiceSelectDialog.vue' import type { ViewField } from '@/components/business/system-view-dialog/types' import { bxViewKv, bxViewKvLong, bxViewKvMoney } from '@/utils/bx/view-kv' import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary' +import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment' +import { pickBxData } from '@/utils/http/bx' type DialogKey = 'add' | 'view' | 'viewFlow' @@ -639,11 +683,13 @@ const flowLoading = ref(false) const viewFlowNodes = ref([]) const viewApprovalHistory = ref([]) const viewFlowLoading = ref(false) -const uploadedFiles = ref([]) -const attachmentUploadRef = ref | null>(null) const viewAttachments = ref([]) const viewPayAttachments = ref([]) const viewOffsetAttachments = ref([]) +const viewLinkedInvoices = ref([]) +const selectedInvoices = ref([]) +const invoiceSelectVisible = ref(false) +const walletBalance = ref(0) const editingId = ref(null) const titleAutoLinked = ref(true) @@ -679,16 +725,34 @@ async function onReimburseDateChange() { } } +async function refreshWalletBalance() { + try { + const res: any = await invoiceWalletApi.mineSummary() + const data: any = pickBxData(res) || {} + walletBalance.value = Number(data.balance ?? 0) + } catch { + walletBalance.value = 0 + } +} + +function onInvoicesPicked(rows: any[]) { + selectedInvoices.value = rows +} + +function removeSelectedInvoice(id: string | number) { + selectedInvoices.value = selectedInvoices.value.filter((i) => String(i.id) !== String(id)) +} + async function openAddDialog() { addForm.reimburseDate = todayYmd() addForm.title = '' addForm.remark = '' addForm.details = [{ expenseItemId: '', amount: '', remark: '' }] - uploadedFiles.value = [] + selectedInvoices.value = [] editingId.value = null titleAutoLinked.value = true dialogs.add = true - await refreshGeneratedTitle() + await Promise.all([refreshGeneratedTitle(), refreshWalletBalance()]) } function isDraftEditable(row?: any) { @@ -770,13 +834,9 @@ async function openEditDialog(row: any) { addForm.details = [{ expenseItemId: '', amount: '', remark: '' }] } editingId.value = row.id - uploadedFiles.value = [] + selectedInvoices.value = Array.isArray(data.invoices) ? data.invoices : [] dialogs.add = true - try { - uploadedFiles.value = await loadBxAttachments('reimburse_apply', row.id) - } catch { - /* ignore attachment fetch error */ - } + await refreshWalletBalance() } catch (e: any) { ElMessage.error(e.message || '加载失败') } @@ -847,6 +907,18 @@ async function handleToolbarSubmit() { ElMessage.warning('请先勾选要提交的报销单') return } + if (!selectedRows.value.every(isDraftEditable)) { + ElMessage.warning('仅草稿或已驳回的报销单可提交') + return + } + await refreshWalletBalance() + const totalNeed = selectedRows.value.reduce((s, r) => s + Number(r.amount || 0), 0) + if (Number(walletBalance.value) < totalNeed) { + ElMessage.warning( + `发票额度不足(余额 ${formatMoneyYuan(walletBalance.value)},本次 ${formatMoneyYuan(totalNeed)})` + ) + return + } loadingMap.submit = true try { for (const row of selectedRows.value) { @@ -872,45 +944,25 @@ async function submitAdd() { ElMessage.warning('请为每条报销明细填写报销事由') return } - if (attachmentUploadRef.value?.hasUploading()) { - ElMessage.warning('附件上传中,请稍候') - return + const invoiceIds = selectedInvoices.value.map((i) => String(i.id)) + const payload = { + reimburseDate: addForm.reimburseDate, + title: addForm.title || undefined, + remark: addForm.remark, + invoiceIds, + details: validDetails.map((d) => ({ + expenseItemId: String(d.expenseItemId), + amount: Number(d.amount), + remark: String(d.remark).trim() + })) } loadingMap.addSubmit = true try { if (editingId.value) { - await reimburseApi.update(editingId.value, { - reimburseDate: addForm.reimburseDate, - title: addForm.title, - details: validDetails.map((d) => ({ - expenseItemId: String(d.expenseItemId), - amount: Number(d.amount), - remark: String(d.remark).trim() - })), - remark: addForm.remark - }) - const newFiles = uploadedFiles.value.filter((f) => !f._existing) - if (newFiles.length) { - const fileIds = newFiles.map((f) => f.id) - await attachmentApi.bindBiz(fileIds, 'reimburse_apply', editingId.value) - } + await reimburseApi.update(editingId.value, payload) ElMessage.success('保存成功') } else { - const res: any = await reimburseApi.create({ - reimburseDate: addForm.reimburseDate, - title: addForm.title || undefined, - remark: addForm.remark, - details: validDetails.map((d) => ({ - expenseItemId: String(d.expenseItemId), - amount: Number(d.amount), - remark: String(d.remark).trim() - })) - }) - const newId = res.data?.id - if (newId && uploadedFiles.value.length) { - const fileIds = uploadedFiles.value.map((f) => f.id) - await attachmentApi.bindBiz(fileIds, 'reimburse_apply', newId) - } + await reimburseApi.create(payload) ElMessage.success('新增成功') } dialogs.add = false @@ -918,7 +970,7 @@ async function submitAdd() { addForm.title = '' addForm.remark = '' addForm.details = [{ expenseItemId: '', amount: '', remark: '' }] - uploadedFiles.value = [] + selectedInvoices.value = [] editingId.value = null await refreshList() } catch (e: any) { @@ -937,6 +989,7 @@ async function openView(row: any) { viewAttachments.value = [] viewPayAttachments.value = [] viewOffsetAttachments.value = [] + viewLinkedInvoices.value = Array.isArray(viewData.value?.invoices) ? viewData.value.invoices : [] dialogs.view = true if (row.id) { try { @@ -1004,6 +1057,13 @@ async function openFlowDialog(row?: any) { } async function handleSubmit(row: any) { + await refreshWalletBalance() + if (Number(walletBalance.value) < Number(row.amount || 0)) { + ElMessage.warning( + `发票额度不足(余额 ${formatMoneyYuan(walletBalance.value)},本次 ${formatMoneyYuan(row.amount)})` + ) + return + } loadingMap[`submit_${row.id}`] = true try { await reimburseApi.submit(row.id) @@ -1063,3 +1123,43 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) { return sums } + + diff --git a/web-admin/src/views/bx/reimburse/approve/index.vue b/web-admin/src/views/bx/reimburse/approve/index.vue index 67ea88ad9..5f0500ac3 100644 --- a/web-admin/src/views/bx/reimburse/approve/index.vue +++ b/web-admin/src/views/bx/reimburse/approve/index.vue @@ -524,8 +524,23 @@ +
+

关联发票

+ + + + + + + + + + + +
+
-

附件

+

历史附件

@@ -699,6 +714,7 @@ const viewFlowNodes = ref([]) const viewApprovalHistory = ref([]) const viewFlowLoading = ref(false) const viewAttachments = ref([]) +const viewLinkedInvoices = ref([]) const approveAction = ref<'approve' | 'reject'>('approve') const approveRows = ref([]) @@ -882,6 +898,7 @@ async function openView(row: any) { viewFlowNodes.value = [] viewApprovalHistory.value = [] viewAttachments.value = [] + viewLinkedInvoices.value = Array.isArray(viewData.value?.invoices) ? viewData.value.invoices : [] dialogs.viewDetail = true viewFlowLoading.value = true try {