|
|
@@ -0,0 +1,796 @@
|
|
|
+<template>
|
|
|
+ <view class="bx-page bx-page--form">
|
|
|
+ <view class="topbar">
|
|
|
+ <view class="topbar-left">
|
|
|
+ <view class="topbar-back" @click="goBack">
|
|
|
+ <text class="topbar-back-icon">‹</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <text class="topbar-title">报销申请</text>
|
|
|
+ <view class="topbar-right" />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="page-padded">
|
|
|
+ <view class="form-card">
|
|
|
+ <view class="form-header">
|
|
|
+ <text class="form-header-title">报销信息</text>
|
|
|
+ <text class="form-badge">{{ isEdit ? '编辑' : '新建' }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">申请人</text>
|
|
|
+ <view class="form-input" style="display: flex; align-items: center">
|
|
|
+ {{ applicantLabel || '当前用户' }}
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">报销日期<text class="req">*</text></text>
|
|
|
+ <picker mode="date" :value="form.reimburseDate" @change="onDateChange">
|
|
|
+ <view class="picker-trigger">
|
|
|
+ <text class="value" :class="{ ph: !form.reimburseDate }">
|
|
|
+ {{ form.reimburseDate || '请选择报销日期' }}
|
|
|
+ </text>
|
|
|
+ <text class="arrow">›</text>
|
|
|
+ </view>
|
|
|
+ </picker>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">报销标题</text>
|
|
|
+ <input
|
|
|
+ class="form-input"
|
|
|
+ :value="form.title"
|
|
|
+ placeholder="不填则自动生成"
|
|
|
+ @input="onTitleInput"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-card">
|
|
|
+ <view class="form-header">
|
|
|
+ <text class="form-header-title">报销明细</text>
|
|
|
+ <text class="form-badge">{{ form.details.length }}行</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-for="(line, idx) in form.details" :key="idx" class="detail-line">
|
|
|
+ <view v-if="idx > 0" class="detail-divider" />
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">
|
|
|
+ 费用项目
|
|
|
+ <text v-if="form.details.length > 1" class="detail-line-no">
|
|
|
+ (第 {{ idx + 1 }} 条)
|
|
|
+ </text>
|
|
|
+ <text class="req">*</text>
|
|
|
+ </text>
|
|
|
+ <view class="picker-trigger" @click="openExpensePicker(idx)">
|
|
|
+ <text
|
|
|
+ class="value"
|
|
|
+ :class="{ ph: !lineExpenseDisplay(line.expenseItemId) }"
|
|
|
+ >
|
|
|
+ {{ lineExpenseDisplay(line.expenseItemId) || '请选择费用项目' }}
|
|
|
+ </text>
|
|
|
+ <text class="arrow">›</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">金额(元)<text class="req">*</text></text>
|
|
|
+ <view class="form-input-prefix">
|
|
|
+ <input
|
|
|
+ class="form-input"
|
|
|
+ type="digit"
|
|
|
+ :value="line.amount"
|
|
|
+ placeholder="0.00"
|
|
|
+ :data-idx="idx"
|
|
|
+ @input="onLineAmount"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">报销事由<text class="req">*</text></text>
|
|
|
+ <input
|
|
|
+ class="form-input"
|
|
|
+ :value="line.remark"
|
|
|
+ placeholder="请输入报销事由"
|
|
|
+ :data-idx="idx"
|
|
|
+ @input="onLineRemark"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">发生时间</text>
|
|
|
+ <picker
|
|
|
+ mode="date"
|
|
|
+ :value="line.occurDate || form.reimburseDate"
|
|
|
+ :data-idx="idx"
|
|
|
+ @change="onOccurDate"
|
|
|
+ >
|
|
|
+ <view class="picker-trigger">
|
|
|
+ <text class="value" :class="{ ph: !line.occurDate }">
|
|
|
+ {{ line.occurDate || '选填' }}
|
|
|
+ </text>
|
|
|
+ <text class="arrow">›</text>
|
|
|
+ </view>
|
|
|
+ </picker>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="form.details.length > 1" class="detail-line-actions">
|
|
|
+ <button class="btn btn-sm btn-danger" @click="removeDetailLine(idx)">
|
|
|
+ 删除此行
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="detail-actions">
|
|
|
+ <button class="btn btn-primary btn-sm" @click="addDetailLine">+ 新增明细行</button>
|
|
|
+ <text class="total-amount">合计:<text style="font-weight: 700">¥{{ detailTotalAmount }}</text></text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-card">
|
|
|
+ <view class="form-header">
|
|
|
+ <text class="form-header-title">关联发票(可选)</text>
|
|
|
+ </view>
|
|
|
+ <view class="invoice-balance-tip">
|
|
|
+ 发票余额 ¥{{ money(walletBalance) }} · 本次合计 ¥{{ detailTotalAmount }}
|
|
|
+ <text v-if="balanceInsufficient" class="invoice-balance-tip--warn">
|
|
|
+ (余额不足,提交后余额将为负,请后续补发票)
|
|
|
+ </text>
|
|
|
+ </view>
|
|
|
+ <button class="btn btn-primary btn-sm" @click="openInvoicePicker">从发票夹选择</button>
|
|
|
+ <view v-if="selectedInvoicePicks.length" class="invoice-picked-row">
|
|
|
+ <view
|
|
|
+ v-for="pick in selectedInvoicePicks"
|
|
|
+ :key="pick.key"
|
|
|
+ class="invoice-picked-item"
|
|
|
+ >
|
|
|
+ <view class="invoice-picked-item__meta">
|
|
|
+ <text class="invoice-picked-item__name">{{ pick.fileName }}</text>
|
|
|
+ <text class="invoice-picked-item__amt">整票 ¥{{ money(pick.amount) }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="invoice-picked-item__remove" @click="removeInvoicePick(pick.key)">×</view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-card">
|
|
|
+ <view class="form-header">
|
|
|
+ <text class="form-header-title">申请附件(可选)</text>
|
|
|
+ </view>
|
|
|
+ <button
|
|
|
+ class="btn btn-primary btn-sm"
|
|
|
+ :disabled="uploading"
|
|
|
+ @click="chooseAttachments"
|
|
|
+ >
|
|
|
+ {{ uploading ? '上传中…' : '上传图片' }}
|
|
|
+ </button>
|
|
|
+ <view v-if="files.length" class="att-grid">
|
|
|
+ <view v-for="(file, idx) in files" :key="file.id" class="att-img">
|
|
|
+ <image :src="file.previewUrl || file.localPath" mode="aspectFill" />
|
|
|
+ <view v-if="!file._pending" class="del" @click.stop="removeFile(idx)">×</view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view v-else class="detail-section-empty" style="margin-top: 8px">暂无附件(可选)</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-card">
|
|
|
+ <view class="form-header">
|
|
|
+ <text class="form-header-title">报销说明</text>
|
|
|
+ </view>
|
|
|
+ <view class="field">
|
|
|
+ <textarea
|
|
|
+ class="form-textarea"
|
|
|
+ :value="form.remark"
|
|
|
+ placeholder="请输入报销说明"
|
|
|
+ :maxlength="500"
|
|
|
+ @input="onRemarkInput"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="bottom-action-bar">
|
|
|
+ <button class="btn btn-primary btn-block" :disabled="submitting" @click="onSubmit">
|
|
|
+ {{ submitting ? '保存中…' : '保存' }}
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 费用项目选择 -->
|
|
|
+ <view v-if="showExpensePicker" class="bx-sheet-mask" @click="showExpensePicker = false">
|
|
|
+ <view class="bx-sheet" @click.stop>
|
|
|
+ <view class="bx-sheet__title">选择费用项目</view>
|
|
|
+ <scroll-view scroll-y class="bx-sheet__body">
|
|
|
+ <view v-if="!expenseOptions.length" class="bx-sheet__empty">暂无可用费用项目</view>
|
|
|
+ <view
|
|
|
+ v-for="opt in expenseOptions"
|
|
|
+ :key="opt.id"
|
|
|
+ class="bx-sheet__item"
|
|
|
+ :class="{ 'is-selected': String(form.details[pickingLineIndex]?.expenseItemId) === opt.id }"
|
|
|
+ @click="confirmExpensePick(opt.id)"
|
|
|
+ >
|
|
|
+ {{ opt.label }}
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 发票多选(扁平列表) -->
|
|
|
+ <view v-if="showInvoicePicker" class="bx-sheet-mask" @click="showInvoicePicker = false">
|
|
|
+ <view class="bx-sheet" @click.stop>
|
|
|
+ <view class="bx-sheet__title">选择发票附件</view>
|
|
|
+ <scroll-view scroll-y class="bx-sheet__body">
|
|
|
+ <view v-if="!attachItems.length" class="bx-sheet__empty">暂无发票附件</view>
|
|
|
+ <view
|
|
|
+ v-for="item in attachItems"
|
|
|
+ :key="item.key"
|
|
|
+ class="bx-sheet__item"
|
|
|
+ :class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
|
|
|
+ @click="togglePickerKey(item.key)"
|
|
|
+ >
|
|
|
+ <view>
|
|
|
+ <text>{{ item.fileName }}</text>
|
|
|
+ <view class="bx-sheet__item-sub">整票 ¥{{ money(item.amount) }}</view>
|
|
|
+ </view>
|
|
|
+ <text v-if="pickerDraftKeys.includes(item.key)">✓</text>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ <view class="bx-sheet__footer">
|
|
|
+ <button class="btn btn-primary btn-block" @click="confirmInvoicePicker">
|
|
|
+ 确定(已选 {{ pickerDraftKeys.length }})
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, reactive, computed } from 'vue'
|
|
|
+import { onLoad } from '@dcloudio/uni-app'
|
|
|
+import { PAGE_PATHS } from '@/config/api'
|
|
|
+import { useUserStore } from '@/stores/user'
|
|
|
+import {
|
|
|
+ reimburseApi,
|
|
|
+ attachmentApi,
|
|
|
+ expenseItemApi,
|
|
|
+ invoiceWalletApi,
|
|
|
+ invoiceApi
|
|
|
+} from '@/api/bx'
|
|
|
+import {
|
|
|
+ normalizeExpenseItem,
|
|
|
+ flattenExpenseTree,
|
|
|
+ formatExpenseName
|
|
|
+} from '@/utils/bxFormat'
|
|
|
+
|
|
|
+const MAX_ATTACHMENTS = 10
|
|
|
+const userStore = useUserStore()
|
|
|
+
|
|
|
+const editId = ref('')
|
|
|
+const savedId = ref('')
|
|
|
+const submitting = ref(false)
|
|
|
+const uploading = ref(false)
|
|
|
+const files = ref([])
|
|
|
+
|
|
|
+function todayYmd() {
|
|
|
+ const d = new Date()
|
|
|
+ const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
|
+ const day = String(d.getDate()).padStart(2, '0')
|
|
|
+ return `${d.getFullYear()}-${m}-${day}`
|
|
|
+}
|
|
|
+
|
|
|
+const form = reactive({
|
|
|
+ title: '',
|
|
|
+ remark: '',
|
|
|
+ applicantId: '',
|
|
|
+ reimburseDate: todayYmd(),
|
|
|
+ details: [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
|
|
|
+})
|
|
|
+
|
|
|
+const flatList = ref([])
|
|
|
+const showExpensePicker = ref(false)
|
|
|
+const pickingLineIndex = ref(0)
|
|
|
+
|
|
|
+const walletBalance = ref(0)
|
|
|
+const attachItems = ref([])
|
|
|
+const selectedInvoicePicks = ref([])
|
|
|
+const pickerDraftKeys = ref([])
|
|
|
+const showInvoicePicker = ref(false)
|
|
|
+
|
|
|
+const isEdit = computed(() => !!editId.value || !!savedId.value)
|
|
|
+
|
|
|
+const applicantLabel = computed(() => {
|
|
|
+ const u = userStore.userInfo || {}
|
|
|
+ return u.realName || u.username || u.nickName || u.name || ''
|
|
|
+})
|
|
|
+
|
|
|
+const expenseOptions = computed(() => {
|
|
|
+ return (flatList.value || [])
|
|
|
+ .filter((i) => i.status === 1 || i.status === '1')
|
|
|
+ .map((i) => ({
|
|
|
+ id: String(i.id),
|
|
|
+ label: formatExpenseName(i.id, flatList.value)
|
|
|
+ }))
|
|
|
+})
|
|
|
+
|
|
|
+const detailTotalNumber = computed(() =>
|
|
|
+ form.details.reduce((sum, 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
|
|
|
+)
|
|
|
+
|
|
|
+function money(v) {
|
|
|
+ return Number(v || 0).toLocaleString('zh-CN', {
|
|
|
+ minimumFractionDigits: 2,
|
|
|
+ maximumFractionDigits: 2
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function lineExpenseDisplay(id) {
|
|
|
+ if (id === '' || id == null) return ''
|
|
|
+ const name = formatExpenseName(id, flatList.value)
|
|
|
+ return name === '-' ? '' : name
|
|
|
+}
|
|
|
+
|
|
|
+function onTitleInput(e) {
|
|
|
+ form.title = e.detail.value
|
|
|
+}
|
|
|
+function onRemarkInput(e) {
|
|
|
+ form.remark = e.detail.value
|
|
|
+}
|
|
|
+function onLineAmount(e) {
|
|
|
+ const idx = Number(e.currentTarget.dataset.idx)
|
|
|
+ if (Number.isNaN(idx) || !form.details[idx]) return
|
|
|
+ form.details[idx].amount = e.detail.value
|
|
|
+}
|
|
|
+function onLineRemark(e) {
|
|
|
+ const idx = Number(e.currentTarget.dataset.idx)
|
|
|
+ if (Number.isNaN(idx) || !form.details[idx]) return
|
|
|
+ form.details[idx].remark = e.detail.value
|
|
|
+}
|
|
|
+function onOccurDate(e) {
|
|
|
+ const idx = Number(e.currentTarget.dataset.idx)
|
|
|
+ if (Number.isNaN(idx) || !form.details[idx]) return
|
|
|
+ form.details[idx].occurDate = e.detail.value
|
|
|
+}
|
|
|
+function onDateChange(e) {
|
|
|
+ form.reimburseDate = e.detail.value
|
|
|
+ refreshTitle()
|
|
|
+}
|
|
|
+
|
|
|
+function goBack() {
|
|
|
+ uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
|
|
|
+}
|
|
|
+
|
|
|
+function addDetailLine() {
|
|
|
+ form.details.push({ expenseItemId: '', amount: '', remark: '', occurDate: '' })
|
|
|
+}
|
|
|
+
|
|
|
+function removeDetailLine(index) {
|
|
|
+ form.details.splice(index, 1)
|
|
|
+}
|
|
|
+
|
|
|
+function openExpensePicker(idx) {
|
|
|
+ pickingLineIndex.value = idx
|
|
|
+ showExpensePicker.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function confirmExpensePick(id) {
|
|
|
+ form.details[pickingLineIndex.value].expenseItemId = String(id)
|
|
|
+ showExpensePicker.value = false
|
|
|
+}
|
|
|
+
|
|
|
+function invoiceFileList(inv) {
|
|
|
+ const raw = Array.isArray(inv?.attachments) ? inv.attachments : []
|
|
|
+ if (raw.length) {
|
|
|
+ return raw.map((a) => ({
|
|
|
+ id: String(a.id || a.attachmentId),
|
|
|
+ fileName: a.fileName || a.file_name || '发票附件',
|
|
|
+ fileType: a.fileType || a.file_type || ''
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ const aid = inv?.attachmentId || inv?.attachment_id
|
|
|
+ if (aid) {
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ id: String(aid),
|
|
|
+ fileName: inv.fileName || inv.file_name || '发票附件',
|
|
|
+ fileType: inv.fileType || inv.file_type || ''
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ return []
|
|
|
+}
|
|
|
+
|
|
|
+function buildAttachItems(rows) {
|
|
|
+ const items = []
|
|
|
+ for (const row of rows || []) {
|
|
|
+ const createTime = row.createTime || row.create_time || ''
|
|
|
+ for (const file of invoiceFileList(row)) {
|
|
|
+ if (!file.id) continue
|
|
|
+ items.push({
|
|
|
+ key: `${row.id}:${file.id}`,
|
|
|
+ invoiceId: String(row.id),
|
|
|
+ attachmentId: file.id,
|
|
|
+ amount: row.amount,
|
|
|
+ createTime,
|
|
|
+ fileName: file.fileName,
|
|
|
+ fileType: file.fileType
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return items
|
|
|
+}
|
|
|
+
|
|
|
+function buildInvoicePayload() {
|
|
|
+ const invoiceLinks = selectedInvoicePicks.value.map((p) => ({
|
|
|
+ invoiceId: String(p.invoiceId),
|
|
|
+ attachmentId: String(p.attachmentId)
|
|
|
+ }))
|
|
|
+ const invoiceIds = Array.from(new Set(invoiceLinks.map((p) => p.invoiceId)))
|
|
|
+ return { invoiceIds, invoiceLinks }
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshTitle() {
|
|
|
+ if (!form.reimburseDate) return
|
|
|
+ try {
|
|
|
+ const params = { reimburseDate: form.reimburseDate }
|
|
|
+ const exclude = editId.value || savedId.value
|
|
|
+ if (exclude) params.excludeApplyId = exclude
|
|
|
+ if (form.applicantId) params.applicantId = form.applicantId
|
|
|
+ const res = await reimburseApi.generateTitle(params)
|
|
|
+ const t = res?.data
|
|
|
+ form.title = typeof t === 'string' ? t : t?.title || form.title
|
|
|
+ } catch (e) {
|
|
|
+ // keep
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshWallet() {
|
|
|
+ try {
|
|
|
+ const owner = form.applicantId || undefined
|
|
|
+ const [sumRes, invRes] = await Promise.all([
|
|
|
+ invoiceWalletApi.mineSummary(owner ? { userId: owner } : undefined),
|
|
|
+ invoiceApi.list({ page: 1, pageSize: 200, userId: owner })
|
|
|
+ ])
|
|
|
+ walletBalance.value = Number(sumRes?.data?.balance ?? 0)
|
|
|
+ const d = invRes?.data
|
|
|
+ const rows = d?.rows ?? d?.items ?? d?.list ?? (Array.isArray(d) ? d : [])
|
|
|
+ attachItems.value = buildAttachItems(rows)
|
|
|
+ } catch (e) {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function openInvoicePicker() {
|
|
|
+ pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
|
|
|
+ showInvoicePicker.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function togglePickerKey(key) {
|
|
|
+ const i = pickerDraftKeys.value.indexOf(key)
|
|
|
+ if (i >= 0) pickerDraftKeys.value.splice(i, 1)
|
|
|
+ else pickerDraftKeys.value.push(key)
|
|
|
+}
|
|
|
+
|
|
|
+function confirmInvoicePicker() {
|
|
|
+ const set = new Set(pickerDraftKeys.value)
|
|
|
+ selectedInvoicePicks.value = attachItems.value
|
|
|
+ .filter((i) => set.has(i.key))
|
|
|
+ .map((i) => ({ ...i }))
|
|
|
+ showInvoicePicker.value = false
|
|
|
+}
|
|
|
+
|
|
|
+function removeInvoicePick(key) {
|
|
|
+ selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
|
|
|
+}
|
|
|
+
|
|
|
+function validateReimburseApplyForm() {
|
|
|
+ for (let i = 0; i < form.details.length; i++) {
|
|
|
+ const d = form.details[i]
|
|
|
+ const row = i + 1
|
|
|
+ const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
|
|
|
+ const amountStr = String(d.amount ?? '').trim()
|
|
|
+ const hasAmount = amountStr !== ''
|
|
|
+ const amt = Number(amountStr)
|
|
|
+ const amountOk = hasAmount && !Number.isNaN(amt) && amt > 0
|
|
|
+ const hasRemark = !!d.remark?.trim()
|
|
|
+
|
|
|
+ if (!hasExpense && !hasAmount && !hasRemark) continue
|
|
|
+
|
|
|
+ if (!hasExpense) {
|
|
|
+ uni.showToast({ title: `第 ${row} 行:请选择费用项目`, icon: 'none' })
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ if (!amountOk) {
|
|
|
+ uni.showToast({ title: `第 ${row} 行:请输入大于 0 的金额`, icon: 'none' })
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ if (!d.remark?.trim()) {
|
|
|
+ uni.showToast({ title: `第 ${row} 行:请填写报销事由`, icon: 'none' })
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const validDetails = form.details.filter((d) => {
|
|
|
+ const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
|
|
|
+ const amt = Number(String(d.amount ?? '').trim())
|
|
|
+ return hasExpense && !Number.isNaN(amt) && amt > 0
|
|
|
+ })
|
|
|
+ if (validDetails.length === 0) {
|
|
|
+ uni.showToast({ title: '请至少填写一条完整的报销明细', icon: 'none' })
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ if (!form.reimburseDate) {
|
|
|
+ uni.showToast({ title: '请选择报销日期', icon: 'none' })
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ return validDetails
|
|
|
+}
|
|
|
+
|
|
|
+async function chooseAttachments() {
|
|
|
+ const remain = MAX_ATTACHMENTS - files.value.length
|
|
|
+ if (remain <= 0) {
|
|
|
+ uni.showToast({ title: `最多上传 ${MAX_ATTACHMENTS} 个附件`, icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ uni.chooseImage({
|
|
|
+ count: remain,
|
|
|
+ sizeType: ['compressed'],
|
|
|
+ sourceType: ['album', 'camera'],
|
|
|
+ success: async (res) => {
|
|
|
+ const paths = res.tempFilePaths || []
|
|
|
+ for (const path of paths) {
|
|
|
+ await uploadOne(path)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function uploadOne(filePath) {
|
|
|
+ const pendingId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
|
|
|
+ files.value.push({
|
|
|
+ id: pendingId,
|
|
|
+ previewUrl: filePath,
|
|
|
+ localPath: filePath,
|
|
|
+ _pending: true,
|
|
|
+ _existing: false
|
|
|
+ })
|
|
|
+ uploading.value = true
|
|
|
+ try {
|
|
|
+ const res = await attachmentApi.upload(filePath, '', 0)
|
|
|
+ const data = res?.data || {}
|
|
|
+ const id = String(data.id || '')
|
|
|
+ let previewUrl = filePath
|
|
|
+ if (id) {
|
|
|
+ try {
|
|
|
+ const pRes = await attachmentApi.preview(id)
|
|
|
+ if (pRes?.data?.url) previewUrl = pRes.data.url
|
|
|
+ } catch (e) {
|
|
|
+ // keep local
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const idx = files.value.findIndex((f) => f.id === pendingId)
|
|
|
+ if (idx >= 0) {
|
|
|
+ files.value[idx] = {
|
|
|
+ id: id || pendingId,
|
|
|
+ previewUrl,
|
|
|
+ localPath: filePath,
|
|
|
+ _pending: false,
|
|
|
+ _existing: false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ files.value = files.value.filter((f) => f.id !== pendingId)
|
|
|
+ } finally {
|
|
|
+ uploading.value = files.value.some((f) => f._pending)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function removeFile(idx) {
|
|
|
+ const file = files.value[idx]
|
|
|
+ if (!file) return
|
|
|
+ if (file._existing && file.id && !String(file.id).startsWith('pending_')) {
|
|
|
+ attachmentApi.delete(file.id).catch(() => {})
|
|
|
+ }
|
|
|
+ files.value.splice(idx, 1)
|
|
|
+}
|
|
|
+
|
|
|
+async function bindNewAttachments(bizId) {
|
|
|
+ const newIds = files.value
|
|
|
+ .filter((f) => f.id && !f._existing && !f._pending && !String(f.id).startsWith('pending_'))
|
|
|
+ .map((f) => f.id)
|
|
|
+ if (!newIds.length) return
|
|
|
+ await attachmentApi.bindBiz(newIds, 'reimburse_apply', bizId)
|
|
|
+ files.value.forEach((f) => {
|
|
|
+ if (newIds.includes(f.id)) f._existing = true
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function loadExpenseItems() {
|
|
|
+ try {
|
|
|
+ const res = await expenseItemApi.tree()
|
|
|
+ const tree = (res.data || []).map(normalizeExpenseItem)
|
|
|
+ flatList.value = flattenExpenseTree(tree)
|
|
|
+ } catch (e) {
|
|
|
+ flatList.value = []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadEditData(id) {
|
|
|
+ try {
|
|
|
+ const res = await reimburseApi.getById(id)
|
|
|
+ const data = res?.data || {}
|
|
|
+ form.title = data.title || ''
|
|
|
+ form.remark = data.remark || ''
|
|
|
+ form.applicantId = String(data.applicantId || userStore.userInfo?.id || '')
|
|
|
+ form.reimburseDate = data.reimburseDate || todayYmd()
|
|
|
+ form.details = (data.details || []).map((d) => ({
|
|
|
+ expenseItemId: d.expenseId ?? d.expenseItemId ?? '',
|
|
|
+ amount: String(d.amount ?? ''),
|
|
|
+ remark: d.remark || '',
|
|
|
+ occurDate: d.occurDate || ''
|
|
|
+ }))
|
|
|
+ if (!form.details.length) {
|
|
|
+ form.details = [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
|
|
|
+ }
|
|
|
+
|
|
|
+ await refreshWallet()
|
|
|
+ const linked = data.invoices || []
|
|
|
+ const picks = []
|
|
|
+ for (const inv of linked) {
|
|
|
+ const invoiceId = String(inv.id || inv.invoiceId || '')
|
|
|
+ if (!invoiceId) continue
|
|
|
+ let attachmentId = String(inv.attachmentId || inv.attachment_id || '')
|
|
|
+ let fileName = inv.fileName || inv.file_name || '发票附件'
|
|
|
+ let fileType = inv.fileType || inv.file_type || ''
|
|
|
+ const createTime = inv.createTime || inv.create_time || ''
|
|
|
+ if (!attachmentId) {
|
|
|
+ const src = attachItems.value.find((w) => String(w.invoiceId) === invoiceId)
|
|
|
+ if (src) {
|
|
|
+ attachmentId = src.attachmentId
|
|
|
+ fileName = src.fileName
|
|
|
+ fileType = src.fileType
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!attachmentId) continue
|
|
|
+ picks.push({
|
|
|
+ key: `${invoiceId}:${attachmentId}`,
|
|
|
+ invoiceId,
|
|
|
+ attachmentId,
|
|
|
+ amount: inv.amount,
|
|
|
+ createTime,
|
|
|
+ fileName,
|
|
|
+ fileType
|
|
|
+ })
|
|
|
+ }
|
|
|
+ selectedInvoicePicks.value = picks
|
|
|
+
|
|
|
+ try {
|
|
|
+ const attRes = await attachmentApi.listByBiz('reimburse_apply', id)
|
|
|
+ const list = attRes?.data || []
|
|
|
+ const mapped = []
|
|
|
+ for (const a of list) {
|
|
|
+ let previewUrl = ''
|
|
|
+ if ((a.fileType || '').startsWith('image/')) {
|
|
|
+ try {
|
|
|
+ const pRes = await attachmentApi.preview(a.id)
|
|
|
+ previewUrl = pRes?.data?.url || ''
|
|
|
+ } catch (e) {
|
|
|
+ // skip
|
|
|
+ }
|
|
|
+ }
|
|
|
+ mapped.push({
|
|
|
+ id: String(a.id),
|
|
|
+ previewUrl,
|
|
|
+ _pending: false,
|
|
|
+ _existing: true
|
|
|
+ })
|
|
|
+ }
|
|
|
+ files.value = mapped
|
|
|
+ } catch (e) {
|
|
|
+ files.value = []
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '加载数据失败', icon: 'none' })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function onSubmit() {
|
|
|
+ const validDetails = validateReimburseApplyForm()
|
|
|
+ if (!validDetails) return
|
|
|
+ if (uploading.value) {
|
|
|
+ uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ submitting.value = true
|
|
|
+ const isEditingAction = !!editId.value || !!savedId.value
|
|
|
+ try {
|
|
|
+ const invoicePayload = buildInvoicePayload()
|
|
|
+ const payload = {
|
|
|
+ title: form.title || undefined,
|
|
|
+ remark: form.remark || undefined,
|
|
|
+ applicantId: form.applicantId || undefined,
|
|
|
+ reimburseDate: form.reimburseDate || undefined,
|
|
|
+ details: validDetails.map((d) => ({
|
|
|
+ expenseItemId: String(d.expenseItemId),
|
|
|
+ amount: Number(d.amount),
|
|
|
+ remark: String(d.remark).trim(),
|
|
|
+ occurDate: d.occurDate ? String(d.occurDate) : undefined
|
|
|
+ })),
|
|
|
+ ...invoicePayload
|
|
|
+ }
|
|
|
+
|
|
|
+ let id
|
|
|
+ if (editId.value || savedId.value) {
|
|
|
+ id = savedId.value || editId.value
|
|
|
+ await reimburseApi.update(id, payload)
|
|
|
+ } else {
|
|
|
+ const res = await reimburseApi.create(payload)
|
|
|
+ id = String(res?.data?.id ?? res?.id ?? '')
|
|
|
+ if (!id) throw new Error('保存成功但未返回报销单ID')
|
|
|
+ savedId.value = id
|
|
|
+ editId.value = id
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await bindNewAttachments(id)
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '报销单已保存,但附件关联失败', icon: 'none' })
|
|
|
+ }
|
|
|
+
|
|
|
+ uni.showModal({
|
|
|
+ title: isEditingAction ? '保存成功' : '创建成功',
|
|
|
+ content: '报销单已保存为草稿',
|
|
|
+ confirmText: '提交审批',
|
|
|
+ confirmColor: '#00a874',
|
|
|
+ cancelText: '继续编辑',
|
|
|
+ success: async (modalRes) => {
|
|
|
+ if (!modalRes.confirm) return
|
|
|
+ try {
|
|
|
+ await reimburseApi.submit(id, invoicePayload)
|
|
|
+ uni.showToast({ title: '提交成功', icon: 'success' })
|
|
|
+ setTimeout(() => goBack(), 500)
|
|
|
+ } catch (e) {
|
|
|
+ // toasted
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } catch (e) {
|
|
|
+ // toasted by bxRequest
|
|
|
+ } finally {
|
|
|
+ submitting.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onLoad(async (query) => {
|
|
|
+ const uid = String(userStore.userInfo?.id || userStore.userInfo?.userId || '')
|
|
|
+ form.applicantId = uid
|
|
|
+ await loadExpenseItems()
|
|
|
+ await refreshWallet()
|
|
|
+ const id = query?.id ? String(query.id) : ''
|
|
|
+ if (id) {
|
|
|
+ editId.value = id
|
|
|
+ await loadEditData(id)
|
|
|
+ } else {
|
|
|
+ await refreshTitle()
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+@import '@/styles/bx-theme.scss';
|
|
|
+</style>
|