Browse Source

feat(bx-mobile): add invoice wallet pages and bind reimburse apply to balance rules

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 tháng trước cách đây
mục cha
commit
a8590db98b

+ 17 - 1
bx-mobile/src/api/index.ts

@@ -54,7 +54,8 @@ export const reimburseApi = {
   getApprovals: (id: BxId) => bxClient.get(`/reimburses/${id}/approvals`),
   create: (data: Record<string, any>) => bxClient.post('/reimburses/', data),
   update: (id: BxId, data: Record<string, any>) => bxClient.put(`/reimburses/${id}`, data),
-  submit: (id: BxId) => bxClient.post(`/reimburses/${id}/submit`),
+  submit: (id: BxId, data?: Record<string, any>) =>
+    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<string, any>) => bxClient.get('/payments/pending', { params }),
   pay: (id: BxId, data?: { payRemark?: string; actualAmount?: number }) =>

+ 1 - 1
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

+ 13 - 1
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') },
     ],
   },

+ 267 - 0
bx-mobile/src/views/invoice/InvoiceWalletAdminPage.vue

@@ -0,0 +1,267 @@
+<template>
+  <div class="page">
+    <div class="topbar">
+      <div class="topbar-left">
+        <div class="topbar-back" @click="onBack">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M15 18l-6-6 6-6" />
+          </svg>
+        </div>
+      </div>
+      <span class="topbar-title">{{ detailUser ? detailUser.ownerUserName : '发票夹管理' }}</span>
+      <div class="topbar-right"></div>
+    </div>
+
+    <!-- 员工额度列表 -->
+    <template v-if="!detailUser">
+      <van-search v-model="keyword" placeholder="搜索员工姓名" @search="loadWallets" />
+      <van-pull-refresh v-model="refreshing" @refresh="loadWallets">
+        <div v-if="!wallets.length" class="empty-state empty-state--embed">
+          <p>暂无数据</p>
+        </div>
+        <div
+          v-for="row in wallets"
+          :key="row.ownerUserId"
+          class="list-card"
+          role="button"
+          @click="openDetail(row)"
+        >
+          <div class="list-card-title">{{ row.ownerUserName || row.ownerUserId }}</div>
+          <div class="list-card-kv">
+            <span class="list-card-kv__k">总额 / 已用 / 余额</span>
+            <span class="list-card-kv__v">
+              ¥{{ money(row.totalAmount) }} / ¥{{ money(row.usedAmount) }} /
+              <b>¥{{ money(row.balance) }}</b>
+            </span>
+          </div>
+          <div class="list-card-kv">
+            <span class="list-card-kv__k">发票</span>
+            <span class="list-card-kv__v">{{ row.invoiceCount ?? 0 }} 张</span>
+          </div>
+        </div>
+      </van-pull-refresh>
+    </template>
+
+    <!-- 某人详情 -->
+    <template v-else>
+      <div class="wallet-summary card">
+        <div class="wallet-summary__row">
+          <span>总额</span>
+          <b>¥{{ money(detailSummary.totalAmount) }}</b>
+        </div>
+        <div class="wallet-summary__row">
+          <span>已用</span>
+          <b>¥{{ money(detailSummary.usedAmount) }}</b>
+        </div>
+        <div class="wallet-summary__row wallet-summary__row--em">
+          <span>余额</span>
+          <b>¥{{ money(detailSummary.balance) }}</b>
+        </div>
+      </div>
+      <div class="wallet-actions">
+        <button type="button" class="btn btn-primary btn-block" @click="showUpload = true">
+          代传发票
+        </button>
+      </div>
+      <div v-for="inv in detailInvoices" :key="inv.id" class="list-card">
+        <div class="list-card-kv">
+          <span class="list-card-kv__k">金额</span>
+          <span class="list-card-kv__v">¥{{ money(inv.amount) }}</span>
+        </div>
+        <div class="list-card-kv">
+          <span class="list-card-kv__k">备注</span>
+          <span class="list-card-kv__v">{{ inv.remark || '-' }}</span>
+        </div>
+        <button type="button" class="btn btn-ghost btn-sm danger" @click="onDelete(inv)">
+          删除发票
+        </button>
+      </div>
+    </template>
+
+    <van-dialog
+      v-model:show="showUpload"
+      title="代传发票"
+      show-cancel-button
+      :confirm-button-color="PRIMARY_CONFIRM_BUTTON_COLOR"
+      :before-close="onUploadBeforeClose"
+    >
+      <div class="dialog-form-body">
+        <van-field v-model="uploadAmount" type="number" label="金额" placeholder="票面入账金额" />
+        <van-field v-model="uploadRemark" label="备注" placeholder="可选" />
+        <div class="upload-picker">
+          <van-uploader v-model="uploadFiles" :max-count="5" multiple accept="image/*,.pdf" />
+        </div>
+      </div>
+    </van-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { showConfirmDialog, showToast } from 'vant'
+import { DELETE_CONFIRM_BUTTON_COLOR, PRIMARY_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
+import { invoiceApi, invoiceWalletApi } from '@/api'
+import { BizError } from '@/api/client'
+
+const router = useRouter()
+const keyword = ref('')
+const refreshing = ref(false)
+const wallets = ref<any[]>([])
+const detailUser = ref<any>(null)
+const detailSummary = reactive({
+  totalAmount: 0,
+  usedAmount: 0,
+  balance: 0,
+  invoiceCount: 0,
+})
+const detailInvoices = ref<any[]>([])
+
+const showUpload = ref(false)
+const uploadAmount = ref('')
+const uploadRemark = ref('')
+const uploadFiles = ref<any[]>([])
+const uploading = ref(false)
+
+function money(v: unknown) {
+  return Number(v || 0).toLocaleString('zh-CN', {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2,
+  })
+}
+
+function paged(res: any): any[] {
+  const d = res?.data
+  if (!d) return []
+  return d.rows ?? d.items ?? d.list ?? (Array.isArray(d) ? d : [])
+}
+
+async function loadWallets() {
+  refreshing.value = true
+  try {
+    const res: any = await invoiceWalletApi.list({
+      page: 1,
+      pageSize: 100,
+      keyword: keyword.value || undefined,
+    })
+    wallets.value = paged(res)
+  } catch (e: any) {
+    if (!(e instanceof BizError)) showToast(e?.message || '加载失败')
+  } finally {
+    refreshing.value = false
+  }
+}
+
+async function openDetail(row: any) {
+  detailUser.value = row
+  try {
+    const [sumRes, invRes]: any[] = await Promise.all([
+      invoiceWalletApi.mineSummary({ userId: row.ownerUserId }),
+      invoiceApi.list({ page: 1, pageSize: 100, userId: row.ownerUserId }),
+    ])
+    Object.assign(detailSummary, sumRes?.data || row)
+    detailInvoices.value = paged(invRes)
+  } catch (e: any) {
+    if (!(e instanceof BizError)) showToast(e?.message || '加载失败')
+  }
+}
+
+function onBack() {
+  if (detailUser.value) {
+    detailUser.value = null
+    loadWallets()
+    return
+  }
+  router.back()
+}
+
+async function onDelete(inv: any) {
+  try {
+    await showConfirmDialog({
+      title: '删除发票',
+      message: '删除将冲减总额,且余额不可为负。确定删除?',
+      confirmButtonColor: DELETE_CONFIRM_BUTTON_COLOR,
+    })
+    await invoiceApi.delete(inv.id)
+    showToast('已删除')
+    await openDetail(detailUser.value)
+  } catch (e: any) {
+    if (e !== 'cancel' && !(e instanceof BizError)) showToast(e?.message || '删除失败')
+  }
+}
+
+async function onUploadBeforeClose(action: string) {
+  if (action !== 'confirm') return true
+  if (!detailUser.value) return false
+  const amount = Number(uploadAmount.value)
+  if (!(amount > 0)) {
+    showToast('请输入有效金额')
+    return false
+  }
+  const files = uploadFiles.value.map((f) => f.file).filter(Boolean) as File[]
+  if (!files.length) {
+    showToast('请选择发票文件')
+    return false
+  }
+  if (uploading.value) return false
+  uploading.value = true
+  try {
+    const form = new FormData()
+    form.append('amount', String(amount))
+    form.append('targetUserId', String(detailUser.value.ownerUserId))
+    if (uploadRemark.value.trim()) form.append('remark', uploadRemark.value.trim())
+    for (const f of files) form.append('files', f)
+    await invoiceApi.upload(form)
+    showToast('上传成功')
+    uploadAmount.value = ''
+    uploadRemark.value = ''
+    uploadFiles.value = []
+    await openDetail(detailUser.value)
+    return true
+  } catch (e: any) {
+    if (!(e instanceof BizError)) showToast(e?.message || '上传失败')
+    return false
+  } finally {
+    uploading.value = false
+  }
+}
+
+loadWallets()
+</script>
+
+<style scoped>
+.wallet-summary {
+  margin: 12px 16px;
+  padding: 14px 16px;
+}
+.wallet-summary__row {
+  display: flex;
+  justify-content: space-between;
+  padding: 4px 0;
+  font-size: 14px;
+}
+.wallet-summary__row--em b {
+  color: var(--primary, #10b981);
+  font-size: 18px;
+}
+.wallet-actions {
+  padding: 0 16px 12px;
+}
+.list-card {
+  margin: 0 16px 10px;
+  padding: 12px 14px;
+  background: #fff;
+  border-radius: 10px;
+}
+.list-card-title {
+  font-weight: 600;
+  margin-bottom: 6px;
+}
+.danger {
+  color: #dc2626;
+  margin-top: 8px;
+}
+.upload-picker {
+  padding: 8px 16px 16px;
+}
+</style>

+ 239 - 0
bx-mobile/src/views/invoice/InvoiceWalletMinePage.vue

@@ -0,0 +1,239 @@
+<template>
+  <div class="page">
+    <div class="topbar">
+      <div class="topbar-left">
+        <div class="topbar-back" @click="router.back()">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M15 18l-6-6 6-6" />
+          </svg>
+        </div>
+      </div>
+      <span class="topbar-title">我的发票夹</span>
+      <div class="topbar-right"></div>
+    </div>
+
+    <div class="wallet-summary card">
+      <div class="wallet-summary__row">
+        <span>总额</span>
+        <b>¥{{ money(summary.totalAmount) }}</b>
+      </div>
+      <div class="wallet-summary__row">
+        <span>已报销占用</span>
+        <b>¥{{ money(summary.usedAmount) }}</b>
+      </div>
+      <div class="wallet-summary__row wallet-summary__row--em">
+        <span>余额</span>
+        <b>¥{{ money(summary.balance) }}</b>
+      </div>
+      <div class="wallet-summary__meta">发票 {{ summary.invoiceCount ?? 0 }} 张</div>
+    </div>
+
+    <div class="wallet-actions">
+      <button type="button" class="btn btn-primary btn-block" @click="showUpload = true">
+        上传发票
+      </button>
+    </div>
+
+    <van-tabs v-model:active="activeTab" sticky offset-top="46">
+      <van-tab title="发票列表">
+        <van-pull-refresh v-model="refreshing" @refresh="reload">
+          <div v-if="!invoices.length" class="empty-state empty-state--embed">
+            <p>暂无发票</p>
+          </div>
+          <div v-for="inv in invoices" :key="inv.id" class="list-card">
+            <div class="list-card-kv">
+              <span class="list-card-kv__k">金额</span>
+              <span class="list-card-kv__v">¥{{ money(inv.amount) }}</span>
+            </div>
+            <div class="list-card-kv">
+              <span class="list-card-kv__k">备注</span>
+              <span class="list-card-kv__v">{{ inv.remark || '-' }}</span>
+            </div>
+            <div class="list-card-kv">
+              <span class="list-card-kv__k">时间</span>
+              <span class="list-card-kv__v">{{ inv.createTime || '-' }}</span>
+            </div>
+          </div>
+        </van-pull-refresh>
+      </van-tab>
+      <van-tab title="额度流水">
+        <div v-if="!ledgers.length" class="empty-state empty-state--embed">
+          <p>暂无流水</p>
+        </div>
+        <div v-for="(row, idx) in ledgers" :key="row.id || idx" class="list-card">
+          <div class="list-card-kv">
+            <span class="list-card-kv__k">类型</span>
+            <span class="list-card-kv__v">{{ entryTypeLabel(row.entryType) }}</span>
+          </div>
+          <div class="list-card-kv">
+            <span class="list-card-kv__k">金额</span>
+            <span class="list-card-kv__v">¥{{ money(row.amount) }}</span>
+          </div>
+          <div class="list-card-kv">
+            <span class="list-card-kv__k">备注</span>
+            <span class="list-card-kv__v">{{ row.remark || '-' }}</span>
+          </div>
+          <div class="list-card-kv">
+            <span class="list-card-kv__k">时间</span>
+            <span class="list-card-kv__v">{{ row.createTime || '-' }}</span>
+          </div>
+        </div>
+      </van-tab>
+    </van-tabs>
+
+    <van-dialog
+      v-model:show="showUpload"
+      title="上传发票"
+      show-cancel-button
+      :confirm-button-color="PRIMARY_CONFIRM_BUTTON_COLOR"
+      :before-close="onUploadBeforeClose"
+    >
+      <div class="dialog-form-body">
+        <van-field v-model="uploadAmount" type="number" label="金额" placeholder="票面入账金额" />
+        <van-field v-model="uploadRemark" label="备注" placeholder="可选" />
+        <div class="upload-picker">
+          <van-uploader v-model="uploadFiles" :max-count="5" multiple accept="image/*,.pdf" />
+        </div>
+      </div>
+    </van-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { showToast } from 'vant'
+import { PRIMARY_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
+import { invoiceApi, invoiceWalletApi } from '@/api'
+import { BizError } from '@/api/client'
+
+const router = useRouter()
+const activeTab = ref(0)
+const refreshing = ref(false)
+const summary = reactive({
+  totalAmount: 0,
+  usedAmount: 0,
+  balance: 0,
+  invoiceCount: 0,
+})
+const invoices = ref<any[]>([])
+const ledgers = ref<any[]>([])
+
+const showUpload = ref(false)
+const uploadAmount = ref('')
+const uploadRemark = ref('')
+const uploadFiles = ref<any[]>([])
+const uploading = ref(false)
+
+function money(v: unknown) {
+  return Number(v || 0).toLocaleString('zh-CN', {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2,
+  })
+}
+
+function entryTypeLabel(t: string) {
+  const map: Record<string, string> = {
+    credit_upload: '上传入账',
+    debit_delete: '删除冲减',
+    debit_reimburse: '报销占用',
+    credit_release: '驳回/撤回退回',
+  }
+  return map[t] || t || '-'
+}
+
+function paged(res: any): any[] {
+  const d = res?.data
+  if (!d) return []
+  return d.rows ?? d.items ?? d.list ?? (Array.isArray(d) ? d : [])
+}
+
+async function reload() {
+  refreshing.value = true
+  try {
+    const [sumRes, invRes, ledRes]: any[] = await Promise.all([
+      invoiceWalletApi.mineSummary(),
+      invoiceApi.list({ page: 1, pageSize: 100 }),
+      invoiceApi.ledgers(),
+    ])
+    Object.assign(summary, sumRes?.data || {})
+    invoices.value = paged(invRes)
+    ledgers.value = paged(ledRes)
+  } catch (e: any) {
+    if (!(e instanceof BizError)) showToast(e?.message || '加载失败')
+  } finally {
+    refreshing.value = false
+  }
+}
+
+async function onUploadBeforeClose(action: string) {
+  if (action !== 'confirm') return true
+  const amount = Number(uploadAmount.value)
+  if (!(amount > 0)) {
+    showToast('请输入有效金额')
+    return false
+  }
+  const files = uploadFiles.value.map((f) => f.file).filter(Boolean) as File[]
+  if (!files.length) {
+    showToast('请选择发票文件')
+    return false
+  }
+  if (uploading.value) return false
+  uploading.value = true
+  try {
+    const form = new FormData()
+    form.append('amount', String(amount))
+    if (uploadRemark.value.trim()) form.append('remark', uploadRemark.value.trim())
+    for (const f of files) form.append('files', f)
+    await invoiceApi.upload(form)
+    showToast('上传成功')
+    uploadAmount.value = ''
+    uploadRemark.value = ''
+    uploadFiles.value = []
+    await reload()
+    return true
+  } catch (e: any) {
+    if (!(e instanceof BizError)) showToast(e?.message || '上传失败')
+    return false
+  } finally {
+    uploading.value = false
+  }
+}
+
+onMounted(reload)
+</script>
+
+<style scoped>
+.wallet-summary {
+  margin: 12px 16px;
+  padding: 14px 16px;
+}
+.wallet-summary__row {
+  display: flex;
+  justify-content: space-between;
+  padding: 4px 0;
+  font-size: 14px;
+  color: var(--text-secondary);
+}
+.wallet-summary__row--em b {
+  color: var(--primary, #10b981);
+  font-size: 18px;
+}
+.wallet-summary__meta {
+  margin-top: 8px;
+  font-size: 12px;
+  color: var(--text-muted);
+}
+.wallet-actions {
+  padding: 0 16px 12px;
+}
+.list-card {
+  margin: 0 16px 10px;
+  padding: 12px 14px;
+  background: #fff;
+  border-radius: 10px;
+}
+.upload-picker {
+  padding: 8px 16px 16px;
+}
+</style>

+ 206 - 118
bx-mobile/src/views/reimburse/ReimburseApplyPage.vue

@@ -93,47 +93,60 @@
           </div>
         </div>
 
-        <!-- 附件(与备用金申请页结构、样式一致) -->
+        <!-- 发票夹:可选关联 + 余额提示(不再本地上传发票) -->
         <div class="form-card">
           <div class="form-header">
-            <h3>附件</h3>
+            <h3>关联发票(可选)</h3>
           </div>
-          <div class="uploader-slot-btn advance-attachment-upload">
-            <van-uploader
-              :after-read="onAfterRead"
-              :before-read="beforeReadAttachment"
-              multiple
-              result-type="file"
-              :accept="attachmentAccept"
-            >
-              <button type="button" class="btn btn-primary btn-sm">上传附件</button>
-            </van-uploader>
-          </div>
-          <div v-if="attachment.files.value.length" class="att-grid advance-att-grid">
+          <p class="invoice-balance-tip">
+            发票余额 <b>¥{{ money(walletBalance) }}</b> · 本次合计
+            <b>¥{{ detailTotalAmount }}</b>
+            <span v-if="balanceInsufficient" class="invoice-balance-tip--warn">(余额不足,无法提交)</span>
+          </p>
+          <button type="button" class="btn btn-primary btn-sm" @click="openInvoicePicker">
+            从发票夹选择
+          </button>
+          <div v-if="selectedInvoiceIds.length" class="invoice-picked">
             <div
-              v-for="(file, aidx) in attachment.files.value"
-              :key="file.id"
-              class="att-img advance-att-tile"
-              :title="file.fileName"
-              role="button"
-              tabindex="0"
-              @click="attachment.open(file)"
-              @keydown.enter.prevent="attachment.open(file)"
+              v-for="inv in selectedInvoiceRows"
+              :key="inv.id"
+              class="invoice-picked__item"
             >
-              <img
-                v-if="attachment.isImage(file.fileType) && file.previewUrl"
-                :src="file.previewUrl"
-                :alt="file.fileName"
-              />
-              <div v-else class="att-file-icon att-doc-thumb">
-                <span class="att-doc-thumb__ext">{{ attachment.fileExt(file.fileName) }}</span>
-                <span class="att-doc-thumb__name">{{ file.fileName }}</span>
-              </div>
-              <button type="button" class="del" @click.stop="attachment.remove(aidx)">×</button>
+              <span>¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }}</span>
+              <button type="button" class="del" @click="removeInvoice(inv.id)">×</button>
             </div>
           </div>
+          <p class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</p>
         </div>
 
+        <van-popup v-model:show="showInvoicePicker" position="bottom" round :style="{ height: '70%' }">
+          <div class="invoice-picker">
+            <div class="invoice-picker__title">选择发票</div>
+            <van-checkbox-group v-model="pickerCheckedIds">
+              <van-cell-group>
+                <van-cell
+                  v-for="inv in walletInvoices"
+                  :key="inv.id"
+                  clickable
+                  @click="togglePickerId(inv.id)"
+                >
+                  <template #title>
+                    ¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }}
+                  </template>
+                  <template #right-icon>
+                    <van-checkbox :name="String(inv.id)" @click.stop />
+                  </template>
+                </van-cell>
+              </van-cell-group>
+            </van-checkbox-group>
+            <div class="invoice-picker__footer">
+              <button type="button" class="btn btn-primary btn-block" @click="confirmInvoicePicker">
+                确定(已选 {{ pickerCheckedIds.length }})
+              </button>
+            </div>
+          </div>
+        </van-popup>
+
         <!-- 报销说明 -->
         <div class="form-card">
           <div class="form-header">
@@ -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<string | null>(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<any[]>([])
+const selectedInvoiceIds = ref<string[]>([])
+const showInvoicePicker = ref(false)
+const pickerCheckedIds = ref<string[]>([])
+
+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 || '提交失败')
+    })
+      .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(() => {
-      // 用户点击"继续编辑" — 留在当前页
-    })
   } 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 */
+    }
   }
 })
 </script>
@@ -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;

+ 1 - 1
docs/business-inventory/03-报销业务明细.md

@@ -282,7 +282,7 @@
 
 | 项 | 说明 |
 |----|------|
-| `bx-mobile` 移动端 | 未迁入门户 |
+| `bx-mobile` 移动端 | H5 适配进行中:P1 门户鉴权+核心单据已对接;P2 发票夹已对齐 |
 | 历史数据迁移 | 方案外,空库启动 |
 | 原 bx 独立用户体系 | 已废弃 |