增加报销模块功能

This commit is contained in:
2026-07-20 17:50:47 +08:00
parent f00ba9f9cc
commit a78666177a
15 changed files with 2706 additions and 248 deletions
+2
View File
@@ -65,6 +65,8 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree'] ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
+90
View File
@@ -0,0 +1,90 @@
import request from '@/utils/request'
export function getReimbursementList(params) {
return request({
url: '/backend/reimbursements',
method: 'get',
params
})
}
export function getReimbursementDetail(id) {
return request({
url: `/backend/reimbursements/${id}`,
method: 'get'
})
}
export function createReimbursement(data) {
return request({
url: '/backend/reimbursements',
method: 'post',
data
})
}
export function updateReimbursement(id, data) {
return request({
url: `/backend/reimbursements/${id}`,
method: 'post',
data
})
}
export function deleteReimbursement(id) {
return request({
url: `/backend/reimbursements/${id}`,
method: 'delete'
})
}
export function submitReimbursement(id) {
return request({
url: `/backend/reimbursements/${id}/submit`,
method: 'post'
})
}
export function getReimbursementRecords(id) {
return request({
url: `/backend/reimbursements/${id}/records`,
method: 'get'
})
}
export function addReimbursementItem(id, data) {
return request({
url: `/backend/reimbursements/${id}/items`,
method: 'post',
data
})
}
export function updateReimbursementItem(id, itemId, data) {
return request({
url: `/backend/reimbursements/${id}/items/${itemId}`,
method: 'post',
data
})
}
export function deleteReimbursementItem(id, itemId) {
return request({
url: `/backend/reimbursements/${id}/items/${itemId}`,
method: 'delete'
})
}
export function getApprovalFlows() {
return request({
url: '/backend/reimbursements/flows',
method: 'get'
})
}
export function getReimbursementExpenseTypes() {
return request({
url: '/backend/reimbursements/types',
method: 'get'
})
}
@@ -0,0 +1,329 @@
<template>
<div class="erp-dashboard">
<!-- 第一行统计卡片 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="4" v-for="(item, index) in firstRowStats" :key="'first-' + index">
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">{{ item.label }}</span>
<el-icon class="stat-icon"><Info-Filled /></el-icon>
</div>
<div class="stat-value">
<span class="currency">¥</span>
<span class="number">{{ formatMoney(item.value) }}</span>
</div>
</div>
</el-col>
</el-row>
<!-- 第二行统计卡片 -->
<el-row :gutter="16" class="stat-row" style="margin-top: 16px;">
<el-col :span="4" v-for="(item, index) in secondRowStats" :key="'second-' + index">
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">{{ item.label }}</span>
<el-icon class="stat-icon"><Info-Filled /></el-icon>
</div>
<div class="stat-value">
<span class="currency">¥</span>
<span class="number">{{ formatMoney(item.value) }}</span>
</div>
</div>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="16" class="chart-row" style="margin-top: 16px;">
<el-col :span="8">
<div class="chart-card">
<div class="chart-title">销售统计</div>
<div ref="salesChartRef" class="chart-container"></div>
</div>
</el-col>
<el-col :span="8">
<div class="chart-card">
<div class="chart-title">零售统计</div>
<div ref="retailChartRef" class="chart-container"></div>
</div>
</el-col>
<el-col :span="8">
<div class="chart-card">
<div class="chart-title">采购统计</div>
<div ref="purchaseChartRef" class="chart-container"></div>
</div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef, nextTick } from 'vue';
import * as echarts from 'echarts';
import { InfoFilled } from '@element-plus/icons-vue';
// 统计卡片数据 - 第一行
const firstRowStats = ref([
{ label: '今日销售', value: 0 },
{ label: '今日零售', value: 0 },
{ label: '今日采购', value: 0 },
{ label: '本月累计销售', value: 0 },
{ label: '本月累计零售', value: 0 },
{ label: '本月累计采购', value: 0 },
]);
// 统计卡片数据 - 第二行
const secondRowStats = ref([
{ label: '昨日销售', value: 0 },
{ label: '昨日零售', value: 0 },
{ label: '昨日采购', value: 0 },
{ label: '今年累计销售', value: 0 },
{ label: '今年累计零售', value: 0 },
{ label: '今年累计采购', value: 0 },
]);
// 图表实例
const salesChartRef = ref<HTMLElement | null>(null);
const retailChartRef = ref<HTMLElement | null>(null);
const purchaseChartRef = ref<HTMLElement | null>(null);
const salesChartInstance = shallowRef<echarts.ECharts | null>(null);
const retailChartInstance = shallowRef<echarts.ECharts | null>(null);
const purchaseChartInstance = shallowRef<echarts.ECharts | null>(null);
// 格式化金额
const formatMoney = (value: number): string => {
return value.toLocaleString();
};
// 生成最近6个月的月份标签
const getMonthLabels = (): string[] => {
const labels: string[] = [];
const now = new Date();
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
labels.push(`${year}-${month}`);
}
return labels;
};
// 初始化图表通用配置
const initChart = (refEl: HTMLElement, title: string, color: string) => {
const chart = echarts.init(refEl);
const months = getMonthLabels();
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: months,
axisLine: { lineStyle: { color: '#E0E6ED' } },
axisLabel: { color: '#606266', fontSize: 11 }
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#F2F6FC' } },
axisLabel: { color: '#606266', fontSize: 11 }
},
series: [
{
name: title,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: color, width: 2 },
itemStyle: { color: color },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color + '40' },
{ offset: 1, color: color + '10' }
])
},
data: [0, 0, 0, 0, 0, 0]
}
]
});
return chart;
};
// 初始化所有图表
const initCharts = () => {
if (salesChartRef.value) {
salesChartInstance.value = initChart(salesChartRef.value, '销售', '#409EFF');
}
if (retailChartRef.value) {
retailChartInstance.value = initChart(retailChartRef.value, '零售', '#67C23A');
}
if (purchaseChartRef.value) {
purchaseChartInstance.value = initChart(purchaseChartRef.value, '采购', '#E6A23C');
}
};
// 加载数据(模拟接口调用)
const loadData = async () => {
// TODO: 调用接口获取真实数据
// const res = await getErpDashboard();
// 模拟数据更新
firstRowStats.value = [
{ label: '今日销售', value: 12580 },
{ label: '今日零售', value: 8560 },
{ label: '今日采购', value: 23400 },
{ label: '本月累计销售', value: 358600 },
{ label: '本月累计零售', value: 245800 },
{ label: '本月累计采购', value: 568900 },
];
secondRowStats.value = [
{ label: '昨日销售', value: 15230 },
{ label: '昨日零售', value: 9870 },
{ label: '昨日采购', value: 18900 },
{ label: '今年累计销售', value: 2856000 },
{ label: '今年累计零售', value: 1985000 },
{ label: '今年累计采购', value: 4568000 },
];
// 更新图表数据
updateCharts();
};
// 更新图表数据
const updateCharts = () => {
const salesData = [28000, 32000, 35000, 38000, 42000, 358600];
const retailData = [18000, 21000, 22000, 25000, 28000, 245800];
const purchaseData = [45000, 48000, 52000, 55000, 58000, 568900];
if (salesChartInstance.value) {
salesChartInstance.value.setOption({ series: [{ data: salesData }] });
}
if (retailChartInstance.value) {
retailChartInstance.value.setOption({ series: [{ data: retailData }] });
}
if (purchaseChartInstance.value) {
purchaseChartInstance.value.setOption({ series: [{ data: purchaseData }] });
}
};
// 自适应窗口大小
const handleResize = () => {
salesChartInstance.value?.resize();
retailChartInstance.value?.resize();
purchaseChartInstance.value?.resize();
};
onMounted(async () => {
initCharts();
await nextTick();
await loadData();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
salesChartInstance.value?.dispose();
retailChartInstance.value?.dispose();
purchaseChartInstance.value?.dispose();
});
</script>
<style lang="less" scoped>
.erp-dashboard {
background-color: #f5f7fa;
min-height: calc(100vh - 84px);
}
.stat-row {
.el-col {
margin-bottom: 0;
}
}
.stat-card {
background: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.stat-label {
font-size: 14px;
color: #606266;
}
.stat-icon {
font-size: 14px;
color: #c0c4cc;
cursor: help;
}
}
.stat-value {
display: flex;
align-items: baseline;
.currency {
font-size: 14px;
color: #303133;
font-weight: 500;
margin-right: 4px;
}
.number {
font-size: 24px;
font-weight: 600;
color: #303133;
font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif;
}
}
}
.chart-row {
.el-col {
margin-bottom: 0;
}
}
.chart-card {
background: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
height: 320px;
display: flex;
flex-direction: column;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.chart-container {
flex: 1;
min-height: 0;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<script setup></script>
<style lang="less" scoped></style>
@@ -0,0 +1,398 @@
<template>
<el-dialog v-model="visible" title="报销详情" width="980px" destroy-on-close>
<el-descriptions v-if="detail" :column="3" border>
<el-descriptions-item label="报销事由" :span="2">{{ detail.title }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="费用类型">{{ detail.expense_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请日期">{{ detail.apply_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="合计金额">¥ {{ money(detail.total_amount) }}</el-descriptions-item>
<el-descriptions-item label="备注说明" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="section-header">
<h4 class="detail-title">费用明细</h4>
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
</div>
<el-table :data="detail?.items || []" border size="small">
<el-table-column prop="expense_type" label="费用类型" min-width="120" />
<el-table-column prop="expense_date" label="发生日期" width="120" />
<el-table-column prop="amount" label="金额" width="110">
<template #default="{ row }">¥ {{ money(row.amount) }}</template>
</el-table-column>
<el-table-column prop="receipt_flag" label="发票" width="70">
<template #default="{ row }">{{ row.receipt_flag ? '有' : '无' }}</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" :width="isDraft ? 176 : 70" fixed="right">
<template #default="{ row }">
<div class="item-actions">
<el-button
v-if="row.receipt_url"
link
type="primary"
title="查看票据"
aria-label="查看票据"
@click="openReceiptPreview(row)"
>
<i class="fa-solid fa-eye"></i>
</el-button>
<template v-if="isDraft">
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/*,.pdf"
:on-change="file => uploadInvoice(row, file)"
:disabled="uploadingItemId === row.id"
>
<el-button
link
type="success"
title="上传发票"
aria-label="上传发票"
>
<i
:class="uploadingItemId === row.id ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
></i>
</el-button>
</el-upload>
<el-button link type="primary" title="编辑" aria-label="编辑" @click="openEditItem(row)">
<i class="fa-solid fa-pen-to-square"></i>
</el-button>
<el-button link type="danger" title="删除" aria-label="删除" @click="removeItem(row)">
<i class="fa-regular fa-trash-can"></i>
</el-button>
</template>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="暂无费用明细" :image-size="60" /></template>
</el-table>
<el-dialog v-model="receiptPreviewVisible" title="查看票据" width="980px" append-to-body destroy-on-close>
<div v-if="receiptList.length" class="receipt-preview">
<aside class="receipt-list">
<button
v-for="receipt in receiptList"
:key="receipt.id"
type="button"
class="receipt-thumb"
:class="{ active: receipt.id === receiptPreviewItemId }"
@click="selectReceipt(receipt)"
>
<el-image
v-if="receipt.isImage"
:src="receipt.url"
fit="cover"
class="receipt-thumb-image"
lazy
/>
<i v-else class="fa-regular fa-file-pdf receipt-thumb-pdf"></i>
<span>{{ receipt.name }}</span>
</button>
</aside>
<main v-if="receiptPreviewUrl" class="receipt-viewer">
<el-image
v-if="isReceiptImage"
:src="receiptPreviewUrl"
:preview-src-list="[receiptPreviewUrl]"
fit="contain"
class="receipt-image"
preview-teleported
/>
<iframe v-else :src="receiptPreviewUrl" title="票据 PDF 预览" class="receipt-pdf"></iframe>
</main>
</div>
</el-dialog>
<h4 class="detail-title">审批记录</h4>
<el-timeline v-if="records.length">
<el-timeline-item
v-for="record in records"
:key="record.id"
:timestamp="formatDateTime(record.create_time)"
placement="top"
>
<strong>{{ actionLabel(record.action) }} · {{ record.approver_name || '-' }}</strong>
<div v-if="record.comment" class="record-comment">{{ record.comment }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无审批记录" :image-size="60" />
<ItemForm
v-model="itemVisible"
:reimbursement-id="detail?.id"
:editing-item-id="editingItemId"
:editing-item="editingItem"
:expense-type-options="expenseTypeOptions"
@saved="handleItemsSaved"
/>
</el-dialog>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadFile } from '@/api/file'
import { deleteReimbursementItem, updateReimbursementItem } from '@/api/reimburse'
import ItemForm from './itemForm.vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
detail: { type: Object, default: null },
records: { type: Array, default: () => [] },
expenseTypeOptions: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'changed'])
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const isDraft = computed(() => props.detail?.status === 0)
const itemVisible = ref(false)
const uploadingItemId = ref(0)
const editingItemId = ref(0)
const editingItem = ref(null)
const receiptPreviewVisible = ref(false)
const receiptPreviewUrl = ref('')
const receiptPreviewType = ref('')
const receiptPreviewItemId = ref(0)
const receiptList = computed(() =>
(props.detail?.items || [])
.filter(item => item.receipt_url)
.map(item => {
const url = resolveReceiptUrl(item.receipt_url)
const isImage = /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(item.receipt_url || '')
return {
id: item.id,
url,
isImage,
name: item.expense_type || `票据 ${item.id}`
}
})
)
const isReceiptImage = computed(() => receiptPreviewType.value.startsWith('image/'))
const resolveReceiptUrl = url => {
if (!url) return ''
if (/^https?:\/\//i.test(url) || url.startsWith('blob:') || url.startsWith('data:')) return url
return `${window.location.origin}/${url.replace(/^\/+/, '')}`
}
const selectReceipt = receipt => {
receiptPreviewItemId.value = receipt.id
receiptPreviewUrl.value = receipt.url
receiptPreviewType.value = receipt.isImage ? 'image/*' : 'application/pdf'
}
const openReceiptPreview = row => {
const receipt = receiptList.value.find(item => item.id === row.id)
if (!receipt) return
selectReceipt(receipt)
receiptPreviewVisible.value = true
}
const openAddItem = () => {
editingItemId.value = 0
editingItem.value = null
itemVisible.value = true
}
const openEditItem = row => {
editingItemId.value = row.id
editingItem.value = row
itemVisible.value = true
}
const handleItemsSaved = reimbursementId => {
itemVisible.value = false
editingItemId.value = 0
editingItem.value = null
emit('changed', reimbursementId)
}
const uploadInvoice = async (row, file) => {
if (!file?.raw) return
const rawFile = file.raw
const isAllowed = rawFile.type.startsWith('image/') || rawFile.type === 'application/pdf'
if (!isAllowed) {
ElMessage.error('发票仅支持图片或 PDF 文件')
return
}
uploadingItemId.value = row.id
try {
const formData = new FormData()
formData.append('file', rawFile)
formData.append('cate', 'reimbursement-invoice')
const uploadResponse = await uploadFile(formData)
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
throw new Error(uploadResponse?.msg || '发票上传失败')
}
await updateReimbursementItem(props.detail.id, row.id, {
expense_type: row.expense_type,
expense_date: row.expense_date,
amount: row.amount,
quantity: row.quantity || 1,
unit_price: row.unit_price || 0,
description: row.description || '',
receipt_flag: 1,
receipt_url: uploadResponse?.data?.url || uploadResponse?.url || ''
})
ElMessage.success('发票上传成功')
emit('changed', props.detail.id)
} catch (error) {
ElMessage.error(error?.message || '发票上传失败')
} finally {
uploadingItemId.value = 0
}
}
const removeItem = async row => {
try {
await ElMessageBox.confirm(`确认删除“${row.expense_type}”这条费用明细吗?`, '删除确认', { type: 'warning' })
await deleteReimbursementItem(props.detail.id, row.id)
ElMessage.success('费用明细已删除')
emit('changed', props.detail.id)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '删除费用明细失败')
}
}
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
{ value: 2, label: '已通过' },
{ value: 3, label: '已驳回' },
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[value] || 'info'
const actionLabel = value => ({ 1: '提交申请', 2: '审批通过', 3: '审批驳回', 4: '转审', 5: '撤回' })[value] || '审批操作'
const money = value => Number(value || 0).toFixed(2)
const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
</script>
<style lang="less" scoped>
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 22px;
}
.detail-title {
margin: 0 0 12px;
color: #303133;
}
.record-comment {
margin-top: 6px;
color: #606266;
}
.receipt-preview {
display: flex;
gap: 16px;
min-height: 620px;
}
.receipt-list {
flex: 0 0 180px;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 12px;
border-right: 1px solid var(--el-border-color-light);
overflow-y: auto;
}
.receipt-thumb {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 72px;
padding: 8px;
overflow: visible;
border: 1px solid var(--el-border-color);
border-radius: 4px;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
cursor: pointer;
text-align: left;
&:hover,
&.active {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
span {
flex: 1;
overflow: visible;
word-break: break-all;
}
}
.receipt-thumb-image {
flex: 0 0 52px;
width: 52px;
height: 56px;
}
.receipt-thumb-pdf {
flex: 0 0 52px;
width: 52px;
color: var(--el-color-danger);
font-size: 34px;
text-align: center;
}
.receipt-viewer {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.receipt-image {
display: block;
width: 100%;
max-width: 100%;
max-height: 620px;
cursor: zoom-in;
}
.receipt-pdf {
width: 100%;
height: 620px;
border: 0;
}
.item-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
:deep(.el-upload) {
display: inline-flex;
}
.el-button {
margin: 0;
padding: 4px 6px;
font-size: 15px;
}
}
</style>
@@ -0,0 +1,73 @@
<template>
<el-dialog v-model="visible" :title="title" width="720px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="申请日期" prop="apply_date">
<el-date-picker
v-model="form.apply_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注说明">
<el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit />
</el-form-item>
<el-alert
title="保存报销单后,可在详情中逐条添加报销事项。"
type="info"
:closable="false"
show-icon
/>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">创建</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '新建报销单' },
formData: { type: Object, required: true },
expenseTypeOptions: { type: Array, default: () => [] },
saving: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'save'])
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const today = () => new Date().toISOString().slice(0, 10)
const cloneForm = data => ({
apply_date: data?.apply_date || today(),
description: data?.description || ''
})
const form = reactive(cloneForm(props.formData))
const formRef = ref()
const formRules = {
apply_date: [{ required: true, message: '请选择申请日期', trigger: 'change' }]
}
watch(() => props.formData, value => {
Object.assign(form, cloneForm(value))
}, { deep: true })
const save = async () => {
if (!(await formRef.value.validate().catch(() => false))) return
emit('save', {
apply_date: form.apply_date,
description: form.description
})
}
</script>
@@ -0,0 +1,252 @@
<template>
<el-dialog
v-model="visible"
:title="editingItemId ? '编辑费用明细' : '批量添加费用明细'"
width="860px"
append-to-body
destroy-on-close
>
<div v-if="!editingItemId" class="batch-toolbar">
<span class="batch-tip">可同时填写多条费用明细保存后将一次性提交</span>
<el-button type="primary" plain size="small" @click="addRow">
<i class="fa-solid fa-plus"></i>
增加一行
</el-button>
</div>
<div class="item-list">
<el-card v-for="(item, index) in items" :key="item.key" shadow="never" class="item-card">
<template #header>
<div class="item-header">
<span>费用明细 {{ index + 1 }}</span>
<el-button
v-if="!editingItemId && items.length > 1"
link
type="danger"
title="删除此行"
aria-label="删除此行"
@click="removeRow(index)"
>
<i class="fa-regular fa-trash-can"></i>
</el-button>
</div>
</template>
<el-form :model="item" :rules="rules" label-width="80px" class="item-form">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="费用类型" prop="expense_type">
<el-select v-model="item.expense_type" placeholder="请选择" style="width: 100%">
<el-option
v-for="option in expenseTypeOptions"
:key="option.id"
:label="option.name"
:value="option.name"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="发生日期" prop="expense_date">
<el-date-picker
v-model="item.expense_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="金额" prop="amount">
<el-input-number
v-model="item.amount"
:min="0.01"
:precision="2"
:step="10"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="费用描述" style="margin-top:20px">
<el-input
v-model="item.description"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
</el-card>
</div>
<el-empty v-if="!items.length" description="请添加费用明细" :image-size="70" />
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="!items.length" @click="submit">
{{ editingItemId ? '保存' : `保存 ${items.length} ` }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { addReimbursementItem, updateReimbursementItem } from '@/api/reimburse'
const props = defineProps({
modelValue: { type: Boolean, default: false },
reimbursementId: { type: [Number, String], default: 0 },
editingItemId: { type: [Number, String], default: 0 },
editingItem: { type: Object, default: null },
expenseTypeOptions: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const saving = ref(false)
const items = ref([])
let rowKey = 0
const today = () => new Date().toISOString().slice(0, 10)
const createRow = (source = {}) => ({
key: ++rowKey,
expense_type: source.expense_type || '',
expense_date: source.expense_date || today(),
amount: Number(source.amount || 0),
description: source.description || '',
unit_price: Number(source.unit_price || 0),
receipt_url: source.receipt_url || ''
})
const rules = {
expense_type: [{ required: true, message: '请选择费用类型', trigger: 'change' }],
expense_date: [{ required: true, message: '请选择发生日期', trigger: 'change' }],
amount: [
{ required: true, message: '请输入费用金额', trigger: 'change' },
{ type: 'number', min: 0.01, message: '金额必须大于0', trigger: 'change' }
]
}
const resetItems = () => {
if (props.editingItemId && props.editingItem) {
items.value = [createRow(props.editingItem)]
} else {
items.value = [createRow()]
}
}
const addRow = () => items.value.push(createRow())
const removeRow = index => items.value.splice(index, 1)
const submit = async () => {
if (!items.value.length) return
const invalidIndex = items.value.findIndex(
item => !item.expense_type || !item.expense_date || Number(item.amount) <= 0
)
if (invalidIndex !== -1) {
ElMessage.warning(`请完善第 ${invalidIndex + 1} 条费用明细`)
return
}
saving.value = true
try {
if (props.editingItemId) {
const item = items.value[0]
await updateReimbursementItem(props.reimbursementId, props.editingItemId, {
expense_type: item.expense_type,
expense_date: item.expense_date,
amount: item.amount,
quantity: 1,
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: props.editingItem?.receipt_flag || 0,
receipt_url: item.receipt_url || ''
})
} else {
for (const item of items.value) {
await addReimbursementItem(props.reimbursementId, {
expense_type: item.expense_type,
expense_date: item.expense_date,
amount: item.amount,
quantity: 1,
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: 0,
receipt_url: ''
})
}
}
ElMessage.success(props.editingItemId ? '费用明细保存成功' : `成功添加 ${items.value.length} 条费用明细`)
visible.value = false
emit('saved', props.reimbursementId)
} catch (error) {
ElMessage.error(error?.message || '保存费用明细失败')
} finally {
saving.value = false
}
}
watch(
() => [props.modelValue, props.editingItemId, props.editingItem],
([isVisible]) => {
if (isVisible) resetItems()
},
{ immediate: true }
)
</script>
<style lang="less" scoped>
.batch-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.batch-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.item-list {
max-height: 540px;
overflow-y: auto;
padding-right: 4px;
}
.item-card {
margin-bottom: 12px;
border: 1px solid var(--el-border-color-light);
&:last-child {
margin-bottom: 0;
}
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.item-form {
:deep(.el-form-item) {
margin-bottom: 14px;
}
:deep(.el-form-item:last-child) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,330 @@
<template>
<div class="reimburse-page">
<div class="page-header">
<div>
<h2>报销管理</h2>
<p>管理个人报销申请及审批进度</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建报销</el-button>
</div>
<div class="filter-card">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item label="关键词">
<el-input v-model="filters.keyword" clearable placeholder="请输入备注说明" @keyup.enter="loadList" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadList">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-card">
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="apply_date" label="申请日期" width="120">
<template #default="{ row }">{{ formatDate(row.apply_date) }}</template>
</el-table-column>
<el-table-column prop="description" label="备注说明" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
<el-table-column prop="total_amount" label="报销金额" width="130">
<template #default="{ row }">¥ {{ money(row.total_amount) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="175">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="245">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button v-if="row.status === 0" link type="primary" @click="openEdit(row.id)">编辑</el-button>
<el-button v-if="row.status === 0" link type="success" @click="handleSubmit(row)">提交</el-button>
<el-button v-if="row.status === 0" link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无报销记录" /></template>
</el-table>
<div class="pagination">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@update:current-page="pagination.page = $event"
@update:page-size="pagination.pageSize = $event"
@current-change="loadList"
@size-change="loadList"
/>
</div>
</div>
<ReimburseEdit
v-model="formVisible"
:title="formTitle"
:form-data="form"
:saving="saving"
@save="saveForm"
/>
<ReimburseDetail
v-model="detailVisible"
:detail="detail"
:records="records"
:expense-type-options="expenseTypeOptions"
@changed="reloadDetail"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import ReimburseEdit from './components/edit.vue'
import ReimburseDetail from './components/detail.vue'
import {
createReimbursement,
deleteReimbursement,
getReimbursementDetail,
getReimbursementExpenseTypes,
getReimbursementList,
getReimbursementRecords,
submitReimbursement,
updateReimbursement
} from '@/api/reimburse'
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
{ value: 2, label: '已通过' },
{ value: 3, label: '已驳回' },
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const filters = reactive({ keyword: '', status: undefined })
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const list = ref([])
const loading = ref(false)
const saving = ref(false)
const formVisible = ref(false)
const detailVisible = ref(false)
const editingId = ref(0)
const detail = ref(null)
const records = ref([])
const expenseTypeOptions = ref([])
const emptyForm = () => ({
apply_date: new Date().toISOString().slice(0, 10),
description: ''
})
const form = reactive(emptyForm())
const formTitle = computed(() => editingId.value ? '编辑报销单' : '新建报销单')
const responseData = res => res?.data?.data ?? res?.data ?? res ?? {}
const loadExpenseTypes = async () => {
try {
const data = responseData(await getReimbursementExpenseTypes())
expenseTypeOptions.value = Array.isArray(data) ? data : data.list || []
} catch (error) {
ElMessage.error(error?.message || '加载费用类型失败')
}
}
const loadList = async () => {
loading.value = true
try {
const data = responseData(await getReimbursementList({ ...filters, ...pagination }))
list.value = data.list || []
pagination.total = Number(data.total || 0)
} catch (error) {
ElMessage.error(error?.message || '加载报销列表失败')
} finally {
loading.value = false
}
}
const resetFilters = () => {
filters.keyword = ''
filters.status = undefined
pagination.page = 1
loadList()
}
const resetForm = () => Object.assign(form, emptyForm())
const openCreate = () => {
editingId.value = 0
resetForm()
formVisible.value = true
}
const openEdit = async id => {
try {
const data = responseData(await getReimbursementDetail(id))
editingId.value = id
Object.assign(form, {
apply_date: data.apply_date || new Date().toISOString().slice(0, 10),
description: data.description || ''
})
formVisible.value = true
} catch (error) {
ElMessage.error(error?.message || '加载报销详情失败')
}
}
const saveForm = async payload => {
saving.value = true
try {
if (editingId.value) await updateReimbursement(editingId.value, payload)
else await createReimbursement(payload)
ElMessage.success('保存成功')
formVisible.value = false
loadList()
} catch (error) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
const handleSubmit = async row => {
try {
await ElMessageBox.confirm('提交后将进入审批流程,确认提交吗?', '提示', { type: 'warning' })
await submitReimbursement(row.id)
ElMessage.success('提交成功')
loadList()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '提交失败')
}
}
const handleDelete = async row => {
try {
await ElMessageBox.confirm('确认删除这条报销记录吗?', '删除确认', { type: 'warning' })
await deleteReimbursement(row.id)
ElMessage.success('删除成功')
loadList()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '删除失败')
}
}
const reloadDetail = async id => {
try {
const [detailRes, recordsRes] = await Promise.all([
getReimbursementDetail(id),
getReimbursementRecords(id)
])
detail.value = responseData(detailRes)
records.value = responseData(recordsRes) || []
await loadList()
} catch (error) {
ElMessage.error(error?.message || '刷新详情失败')
}
}
const openDetail = async id => {
await reloadDetail(id)
detailVisible.value = true
}
const money = value => Number(value || 0).toFixed(2)
const formatDate = value => {
if (!value) return '-'
const text = String(value)
const match = text.match(/^(\d{4}-\d{2}-\d{2})/)
if (match) return match[1]
return text
}
const formatDateTime = value => value
? new Date(value).toLocaleString('zh-CN', { hour12: false })
: '-'
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({
0: 'info',
1: 'warning',
2: 'success',
3: 'danger',
4: 'info',
5: 'success'
})[value] || 'info'
onMounted(() => {
loadExpenseTypes()
loadList()
})
</script>
<style lang="less" scoped>
.reimburse-page {
min-height: calc(100vh - 84px);
background: #f5f7fa;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-header h2 {
margin: 0 0 8px;
color: #303133;
font-size: 22px;
}
.page-header p {
margin: 0;
color: #909399;
font-size: 13px;
}
.filter-card,
.table-card {
background: #fff;
border-radius: 6px;
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
}
.filter-card {
margin-bottom: 16px;
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 768px) {
.reimburse-page {
padding: 12px;
}
.page-header {
align-items: flex-start;
gap: 12px;
}
.filter-card {
overflow-x: auto;
}
}
</style>
+701
View File
@@ -0,0 +1,701 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendReimburseController 报销及审批接口。
type BackendReimburseController struct {
beego.Controller
}
type reimbursementItemPayload struct {
ID uint64 `json:"id"`
ExpenseType string `json:"expense_type"`
ExpenseDate string `json:"expense_date"`
Amount float64 `json:"amount"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
Description string `json:"description"`
ReceiptFlag int8 `json:"receipt_flag"`
ReceiptURL string `json:"receipt_url"`
}
type reimbursementPayload struct {
Title string `json:"title"`
DepartmentID *uint64 `json:"department_id"`
ExpenseType string `json:"expense_type"`
ApplyDate string `json:"apply_date"`
Description string `json:"description"`
Items []reimbursementItemPayload `json:"items"`
}
func (c *BackendReimburseController) claims() (*jwtutil.Claims, bool) {
auth := strings.TrimSpace(c.Ctx.Request.Header.Get("Authorization"))
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, false
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil || claims.UserType != "backend" || claims.TenantId <= 0 || claims.UserID <= 0 {
return nil, false
}
return claims, true
}
func (c *BackendReimburseController) reply(code int, msg string, data interface{}) {
if code != 200 {
c.Ctx.Output.SetStatus(code)
}
result := map[string]interface{}{"code": code, "msg": msg}
if data != nil {
result["data"] = data
}
c.Data["json"] = result
_ = c.ServeJSON()
}
func (c *BackendReimburseController) parsePayload() (reimbursementPayload, error) {
var payload reimbursementPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return payload, err
}
err = json.Unmarshal(raw, &payload)
return payload, err
}
func parseReimburseDate(value string) (time.Time, error) {
return time.Parse("2006-01-02", strings.TrimSpace(value))
}
func (c *BackendReimburseController) getID() (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
return id, err == nil && id > 0
}
func (c *BackendReimburseController) findOwned(id uint64, claims *jwtutil.Claims) (*models.BackendReimbursement, error) {
row := &models.BackendReimbursement{}
err := models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("id", id).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
Filter("is_deleted", 0).
One(row)
return row, err
}
func reimbursementItemDTO(item models.BackendReimbursementItem) map[string]interface{} {
return map[string]interface{}{
"id": item.ID, "reimbursement_id": item.ReimbursementID,
"expense_type": item.ExpenseType, "expense_date": item.ExpenseDate.Format("2006-01-02"),
"amount": item.Amount, "quantity": item.Quantity, "unit_price": item.UnitPrice,
"description": item.Description, "receipt_flag": item.ReceiptFlag, "receipt_url": item.ReceiptURL,
}
}
func reimbursementDTO(row models.BackendReimbursement, items []models.BackendReimbursementItem) map[string]interface{} {
result := map[string]interface{}{
"id": row.ID, "tid": row.Tid, "uid": row.UID, "user_id": row.UserID,
"department_id": row.DepartmentID, "title": row.Title, "total_amount": row.TotalAmount,
"status": row.Status, "expense_type": row.ExpenseType, "apply_date": row.ApplyDate.Format("2006-01-02"),
"description": row.Description, "current_approver_id": row.CurrentApproverID,
"current_step": row.CurrentStep, "create_time": row.CreateTime, "update_time": row.UpdateTime,
}
list := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
list = append(list, reimbursementItemDTO(item))
}
result["items"] = list
return result
}
// List GET /backend/reimbursements
func (c *BackendReimburseController) List() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
keyword := strings.TrimSpace(c.GetString("keyword"))
status, statusErr := c.GetInt("status")
hasStatus := statusErr == nil
qs := models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("tid", claims.TenantId).Filter("user_id", claims.UserID).Filter("is_deleted", 0)
if keyword != "" {
qs = qs.Filter("description__icontains", keyword)
}
if hasStatus {
qs = qs.Filter("status", status)
}
total, err := qs.Count()
if err != nil {
c.reply(500, "查询失败", nil)
return
}
var rows []models.BackendReimbursement
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.reply(500, "查询失败", nil)
return
}
if rows == nil {
rows = []models.BackendReimbursement{}
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
list = append(list, map[string]interface{}{
"id": row.ID,
"tid": row.Tid,
"uid": row.UID,
"user_id": row.UserID,
"department_id": row.DepartmentID,
"title": row.Title,
"total_amount": row.TotalAmount,
"status": row.Status,
"expense_type": row.ExpenseType,
"apply_date": row.ApplyDate.Format("2006-01-02"),
"description": row.Description,
"current_approver_id": row.CurrentApproverID,
"current_step": row.CurrentStep,
"create_time": row.CreateTime,
"update_time": row.UpdateTime,
})
}
c.reply(200, "success", map[string]interface{}{"list": list, "total": total})
}
// Detail GET /backend/reimbursements/:id
func (c *BackendReimburseController) Detail() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
row, err := c.findOwned(id, claims)
if err != nil {
c.reply(404, "报销单不存在", nil)
return
}
var items []models.BackendReimbursementItem
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).
OrderBy("id").All(&items)
if err != nil && err != orm.ErrNoRows {
c.reply(500, "查询明细失败", nil)
return
}
c.reply(200, "success", reimbursementDTO(*row, items))
}
// Create POST /backend/reimbursements
func (c *BackendReimburseController) Create() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
payload, err := c.parsePayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
applyDate, err := parseReimburseDate(payload.ApplyDate)
if err != nil {
c.reply(400, "申请日期格式错误", nil)
return
}
uid := uint64(claims.UserID)
row := &models.BackendReimbursement{
Tid: uint64(claims.TenantId), UID: &uid, UserID: uid, DepartmentID: payload.DepartmentID,
Title: "", TotalAmount: 0, Status: 0,
ExpenseType: strings.TrimSpace(payload.ExpenseType), ApplyDate: applyDate,
Description: nullableReimburseString(payload.Description),
}
if err := saveReimbursement(row); err != nil {
c.reply(500, "创建报销单失败", nil)
return
}
c.reply(200, "success", map[string]interface{}{"id": row.ID})
}
// Update POST /backend/reimbursements/:id
func (c *BackendReimburseController) Update() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
row, err := c.findOwned(id, claims)
if err != nil {
c.reply(404, "报销单不存在", nil)
return
}
if row.Status != 0 {
c.reply(400, "只有草稿可以编辑", nil)
return
}
payload, err := c.parsePayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
applyDate, err := parseReimburseDate(payload.ApplyDate)
if err != nil {
c.reply(400, "申请日期格式错误", nil)
return
}
row.ApplyDate, row.Description = applyDate, nullableReimburseString(payload.Description)
if err := saveReimbursement(row); err != nil {
c.reply(500, "更新报销单失败", nil)
return
}
c.reply(200, "success", map[string]interface{}{"id": row.ID})
}
// Delete DELETE /backend/reimbursements/:id
func (c *BackendReimburseController) Delete() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
row, err := c.findOwned(id, claims)
if err != nil {
c.reply(404, "报销单不存在", nil)
return
}
if row.Status != 0 {
c.reply(400, "只有草稿可以删除", nil)
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
if err != nil {
c.reply(500, "删除失败", nil)
return
}
_, _ = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id", id).Update(orm.Params{"is_deleted": 1, "delete_time": now})
c.reply(200, "success", nil)
}
func (c *BackendReimburseController) parseItemPayload() (reimbursementItemPayload, error) {
var payload reimbursementItemPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return payload, err
}
err = json.Unmarshal(raw, &payload)
return payload, err
}
func (c *BackendReimburseController) getItemID() (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":item_id"), 10, 64)
return id, err == nil && id > 0
}
func (c *BackendReimburseController) findOwnedItem(id, reimbursementID uint64, claims *jwtutil.Claims) (*models.BackendReimbursementItem, error) {
item := &models.BackendReimbursementItem{}
err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("id", id).
Filter("reimbursement_id", reimbursementID).
Filter("tid", claims.TenantId).
Filter("uid", claims.UserID).
Filter("is_deleted", 0).
One(item)
return item, err
}
func validateReimbursementItem(item reimbursementItemPayload) (time.Time, error) {
if strings.TrimSpace(item.ExpenseType) == "" {
return time.Time{}, fmt.Errorf("费用类型不能为空")
}
date, err := parseReimburseDate(item.ExpenseDate)
if err != nil {
return time.Time{}, fmt.Errorf("费用日期格式错误")
}
if item.Amount <= 0 {
return time.Time{}, fmt.Errorf("费用金额必须大于0")
}
return date, nil
}
func (c *BackendReimburseController) ensureDraftReimbursement(id uint64, claims *jwtutil.Claims) (*models.BackendReimbursement, bool) {
row, err := c.findOwned(id, claims)
if err != nil {
c.reply(404, "报销单不存在", nil)
return nil, false
}
if row.Status != 0 {
c.reply(400, "只有草稿可以维护费用明细", nil)
return nil, false
}
return row, true
}
func recalculateReimbursementTotal(id uint64) error {
var items []models.BackendReimbursementItem
_, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id", id).Filter("is_deleted", 0).All(&items)
if err != nil && err != orm.ErrNoRows {
return err
}
var total float64
for _, item := range items {
total += item.Amount
}
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("id", id).Update(orm.Params{
"total_amount": total,
"update_time": time.Now(),
})
return err
}
// AddItem POST /backend/reimbursements/:id/items
func (c *BackendReimburseController) AddItem() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
reimbursementID, ok := c.getID()
if !ok {
c.reply(400, "无效报销单ID", nil)
return
}
if _, ok := c.ensureDraftReimbursement(reimbursementID, claims); !ok {
return
}
payload, err := c.parseItemPayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
date, err := validateReimbursementItem(payload)
if err != nil {
c.reply(400, err.Error(), nil)
return
}
uid := uint64(claims.UserID)
quantity := payload.Quantity
if quantity <= 0 {
quantity = 1
}
item := &models.BackendReimbursementItem{
Tid: uint64(claims.TenantId), UID: &uid, ReimbursementID: reimbursementID,
ExpenseType: strings.TrimSpace(payload.ExpenseType), ExpenseDate: date,
Amount: payload.Amount, Quantity: quantity, UnitPrice: payload.UnitPrice,
Description: strings.TrimSpace(payload.Description), ReceiptFlag: payload.ReceiptFlag,
ReceiptURL: strings.TrimSpace(payload.ReceiptURL),
}
if _, err := models.Orm.Insert(item); err != nil {
c.reply(500, "新增费用明细失败", nil)
return
}
if err := recalculateReimbursementTotal(reimbursementID); err != nil {
c.reply(500, "更新报销总额失败", nil)
return
}
c.reply(200, "success", reimbursementItemDTO(*item))
}
// UpdateItem POST /backend/reimbursements/:id/items/:item_id
func (c *BackendReimburseController) UpdateItem() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
reimbursementID, ok := c.getID()
if !ok {
c.reply(400, "无效报销单ID", nil)
return
}
itemID, ok := c.getItemID()
if !ok {
c.reply(400, "无效费用明细ID", nil)
return
}
if _, ok := c.ensureDraftReimbursement(reimbursementID, claims); !ok {
return
}
item, err := c.findOwnedItem(itemID, reimbursementID, claims)
if err != nil {
c.reply(404, "费用明细不存在", nil)
return
}
payload, err := c.parseItemPayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
date, err := validateReimbursementItem(payload)
if err != nil {
c.reply(400, err.Error(), nil)
return
}
quantity := payload.Quantity
if quantity <= 0 {
quantity = 1
}
item.ExpenseType = strings.TrimSpace(payload.ExpenseType)
item.ExpenseDate = date
item.Amount = payload.Amount
item.Quantity = quantity
item.UnitPrice = payload.UnitPrice
item.Description = strings.TrimSpace(payload.Description)
item.ReceiptFlag = payload.ReceiptFlag
item.ReceiptURL = strings.TrimSpace(payload.ReceiptURL)
if _, err := models.Orm.Update(item); err != nil {
c.reply(500, "更新费用明细失败", nil)
return
}
if err := recalculateReimbursementTotal(reimbursementID); err != nil {
c.reply(500, "更新报销总额失败", nil)
return
}
c.reply(200, "success", reimbursementItemDTO(*item))
}
// DeleteItem DELETE /backend/reimbursements/:id/items/:item_id
func (c *BackendReimburseController) DeleteItem() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
reimbursementID, ok := c.getID()
if !ok {
c.reply(400, "无效报销单ID", nil)
return
}
itemID, ok := c.getItemID()
if !ok {
c.reply(400, "无效费用明细ID", nil)
return
}
if _, ok := c.ensureDraftReimbursement(reimbursementID, claims); !ok {
return
}
item, err := c.findOwnedItem(itemID, reimbursementID, claims)
if err != nil {
c.reply(404, "费用明细不存在", nil)
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("id", item.ID).Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
if err != nil {
c.reply(500, "删除费用明细失败", nil)
return
}
if err := recalculateReimbursementTotal(reimbursementID); err != nil {
c.reply(500, "更新报销总额失败", nil)
return
}
c.reply(200, "success", nil)
}
// Submit POST /backend/reimbursements/:id/submit
func (c *BackendReimburseController) Submit() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
row, err := c.findOwned(id, claims)
if err != nil {
c.reply(404, "报销单不存在", nil)
return
}
if row.Status != 0 {
c.reply(400, "当前状态不可提交", nil)
return
}
var flow models.BackendApprovalFlow
err = models.Orm.QueryTable(new(models.BackendApprovalFlow)).
Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0).
OrderBy("step_order").One(&flow)
if err != nil {
c.reply(400, "尚未配置审批流程", nil)
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": 1, "current_step": flow.StepOrder, "current_approver_id": 0, "update_time": now,
})
if err != nil {
c.reply(500, "提交失败", nil)
return
}
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: 1,
Step: flow.StepOrder, PrevStep: 0,
})
if err != nil {
c.reply(500, "提交审批记录失败", nil)
return
}
c.reply(200, "success", nil)
}
// Records GET /backend/reimbursements/:id/records
func (c *BackendReimburseController) Records() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
if _, err := c.findOwned(id, claims); err != nil {
c.reply(404, "报销单不存在", nil)
return
}
var rows []models.BackendApprovalRecord
_, err := models.Orm.QueryTable(new(models.BackendApprovalRecord)).
Filter("tid", claims.TenantId).Filter("reimbursement_id", id).Filter("is_deleted", 0).
OrderBy("step", "id").All(&rows)
if err != nil && err != orm.ErrNoRows {
c.reply(500, "查询审批记录失败", nil)
return
}
if rows == nil {
rows = []models.BackendApprovalRecord{}
}
c.reply(200, "success", rows)
}
// Types GET /backend/reimbursements/types
func (c *BackendReimburseController) Types() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
var rows []models.BackendReimbursementExpenseType
_, err := models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0).
OrderBy("sort_order", "id").All(&rows)
if err != nil && err != orm.ErrNoRows {
c.reply(500, "查询费用类型失败", nil)
return
}
if rows == nil {
rows = []models.BackendReimbursementExpenseType{}
}
c.reply(200, "success", rows)
}
// Flows GET /backend/reimbursements/flows
func (c *BackendReimburseController) Flows() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
var rows []models.BackendApprovalFlow
_, err := models.Orm.QueryTable(new(models.BackendApprovalFlow)).
Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0).
OrderBy("step_order").All(&rows)
if err != nil && err != orm.ErrNoRows {
c.reply(500, "查询审批流程失败", nil)
return
}
if rows == nil {
rows = []models.BackendApprovalFlow{}
}
c.reply(200, "success", rows)
}
func validateItems(items []reimbursementItemPayload) (float64, error) {
var total float64
for i, item := range items {
if strings.TrimSpace(item.ExpenseType) == "" {
return 0, fmt.Errorf("第%d条费用类型不能为空", i+1)
}
if _, err := parseReimburseDate(item.ExpenseDate); err != nil {
return 0, fmt.Errorf("第%d条费用日期格式错误", i+1)
}
if item.Amount <= 0 {
return 0, fmt.Errorf("第%d条费用金额必须大于0", i+1)
}
if item.Quantity <= 0 {
item.Quantity = 1
}
total += item.Amount
}
return total, nil
}
func saveReimbursement(row *models.BackendReimbursement) error {
if row.ID == 0 {
id, err := models.Orm.Insert(row)
if err != nil {
return err
}
row.ID = uint64(id)
return nil
}
_, err := models.Orm.Update(row)
return err
}
func nullableReimburseString(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func uint64Ptr(value uint64) *uint64 {
return &value
}
+5
View File
@@ -38,6 +38,11 @@ func Init(_ string) {
new(BackendErpOrganization), new(BackendErpOrganization),
new(BackendErpEmployee), new(BackendErpEmployee),
new(BackendErpPosition), new(BackendErpPosition),
new(BackendApprovalFlow),
new(BackendApprovalRecord),
new(BackendReimbursement),
new(BackendReimbursementItem),
new(BackendReimbursementExpenseType),
new(SystemMenu), new(SystemMenu),
new(AdminUser), new(AdminUser),
new(AdminRole), new(AdminRole),
+113
View File
@@ -0,0 +1,113 @@
package models
import "time"
// BackendApprovalFlow 审批流程配置表 yz_backend_approval_flows
type BackendApprovalFlow struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
UID *uint64 `orm:"column(uid);null" json:"uid"`
FlowName string `orm:"column(flow_name);size(100)" json:"flow_name"`
StepOrder int8 `orm:"column(step_order);default(0)" json:"step_order"`
ApproverRole string `orm:"column(approver_role);size(50)" json:"approver_role"`
ApproverRule string `orm:"column(approver_rule);size(200)" json:"approver_rule"`
TimeoutHours int `orm:"column(timeout_hours);default(48)" json:"timeout_hours"`
IsActive int8 `orm:"column(is_active);default(1)" json:"is_active"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
}
func (m *BackendApprovalFlow) TableName() string {
return "yz_backend_approval_flows"
}
// BackendApprovalRecord 审批记录表 yz_backend_approval_records
type BackendApprovalRecord struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
UID *uint64 `orm:"column(uid);null" json:"uid"`
ReimbursementID uint64 `orm:"column(reimbursement_id)" json:"reimbursement_id"`
ApproverID uint64 `orm:"column(approver_id)" json:"approver_id"`
ApproverName string `orm:"column(approver_name);size(100)" json:"approver_name"`
Action int8 `orm:"column(action);default(0)" json:"action"`
Comment *string `orm:"column(comment);null;type(text)" json:"comment"`
Step int8 `orm:"column(step);default(0)" json:"step"`
PrevStep int8 `orm:"column(prev_step);default(0)" json:"prev_step"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
}
func (m *BackendApprovalRecord) TableName() string {
return "yz_backend_approval_records"
}
// BackendReimbursement 报销单主表 yz_backend_reimbursements
type BackendReimbursement struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
UID *uint64 `orm:"column(uid);null" json:"uid"`
UserID uint64 `orm:"column(user_id)" json:"user_id"`
DepartmentID *uint64 `orm:"column(department_id);null" json:"department_id"`
Title string `orm:"column(title);size(200)" json:"title"`
TotalAmount float64 `orm:"column(total_amount);type(decimal);digits(10);decimals(2)" json:"total_amount"`
Status int8 `orm:"column(status);default(0)" json:"status"`
ExpenseType string `orm:"column(expense_type);size(50)" json:"expense_type"`
ApplyDate time.Time `orm:"column(apply_date);type(date)" json:"apply_date"`
Description *string `orm:"column(description);null;type(text)" json:"description"`
CurrentApproverID uint64 `orm:"column(current_approver_id);default(0)" json:"current_approver_id"`
CurrentStep int8 `orm:"column(current_step);default(0)" json:"current_step"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
}
func (m *BackendReimbursement) TableName() string {
return "yz_backend_reimbursements"
}
// BackendReimbursementItem 报销费用明细表 yz_backend_reimbursement_items
type BackendReimbursementItem struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
UID *uint64 `orm:"column(uid);null" json:"uid"`
ReimbursementID uint64 `orm:"column(reimbursement_id)" json:"reimbursement_id"`
ExpenseType string `orm:"column(expense_type);size(50)" json:"expense_type"`
ExpenseDate time.Time `orm:"column(expense_date);type(date)" json:"expense_date"`
Amount float64 `orm:"column(amount);type(decimal);digits(10);decimals(2)" json:"amount"`
Quantity int `orm:"column(quantity);default(1)" json:"quantity"`
UnitPrice float64 `orm:"column(unit_price);type(decimal);digits(10);decimals(2)" json:"unit_price"`
Description string `orm:"column(description);size(200)" json:"description"`
ReceiptFlag int8 `orm:"column(receipt_flag);default(0)" json:"receipt_flag"`
ReceiptURL string `orm:"column(receipt_url);size(500)" json:"receipt_url"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
}
func (m *BackendReimbursementItem) TableName() string {
return "yz_backend_reimbursement_items"
}
// BackendReimbursementExpenseType 报销费用类型表 yz_backend_reimbursement_expense_types
type BackendReimbursementExpenseType struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
UID *uint64 `orm:"column(uid);null" json:"uid"`
Name string `orm:"column(name);size(50)" json:"name"`
SortOrder int `orm:"column(sort_order);default(0)" json:"sort_order"`
IsActive int8 `orm:"column(is_active);default(1)" json:"is_active"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
}
func (m *BackendReimbursementExpenseType) TableName() string {
return "yz_backend_reimbursement_expense_types"
}
+10
View File
@@ -152,6 +152,16 @@ func RegisterAuthRoutes() {
beego.Router("/backend/notebook/update/:id", &controllers.BackendNotebookController{}, "post:Update") beego.Router("/backend/notebook/update/:id", &controllers.BackendNotebookController{}, "post:Update")
beego.Router("/backend/notebook/delete/:id", &controllers.BackendNotebookController{}, "delete:Delete") beego.Router("/backend/notebook/delete/:id", &controllers.BackendNotebookController{}, "delete:Delete")
// 报销与审批管理
beego.Router("/backend/reimbursements", &controllers.BackendReimburseController{}, "get:List;post:Create")
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types")
beego.Router("/backend/reimbursements/flows", &controllers.BackendReimburseController{}, "get:Flows")
beego.Router("/backend/reimbursements/:id/items", &controllers.BackendReimburseController{}, "post:AddItem")
beego.Router("/backend/reimbursements/:id/items/:item_id", &controllers.BackendReimburseController{}, "post:UpdateItem;delete:DeleteItem")
beego.Router("/backend/reimbursements/:id", &controllers.BackendReimburseController{}, "get:Detail;post:Update;delete:Delete")
beego.Router("/backend/reimbursements/:id/submit", &controllers.BackendReimburseController{}, "post:Submit")
beego.Router("/backend/reimbursements/:id/records", &controllers.BackendReimburseController{}, "get:Records")
// 日程提醒管理 // 日程提醒管理
beego.Router("/backend/reminder/list", &controllers.BackendReminderController{}, "get:GetReminderList") beego.Router("/backend/reminder/list", &controllers.BackendReminderController{}, "get:GetReminderList")
beego.Router("/backend/reminder/test", &controllers.BackendReminderController{}, "post:TestReminder") beego.Router("/backend/reminder/test", &controllers.BackendReminderController{}, "post:TestReminder")
@@ -0,0 +1,3 @@
ALTER TABLE `yz_backend_reimbursement_items`
ADD COLUMN `receipt_url` varchar(500) NOT NULL DEFAULT '' COMMENT '票据文件地址' AFTER `receipt_flag`,
ADD KEY `idx_receipt_url` (`receipt_url`);
@@ -0,0 +1,34 @@
-- 报销费用类型表
-- 每个租户维护自己的费用类型,uid 保留用于数据隔离扩展。
CREATE TABLE IF NOT EXISTS `yz_backend_reimbursement_expense_types` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '费用类型名称',
`sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序值,越小越靠前',
`is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用:0-禁用 1-启用',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_tid_name` (`tid`, `name`),
KEY `idx_uid` (`uid`) USING BTREE,
KEY `idx_sort_order` (`sort_order`) USING BTREE,
KEY `idx_is_active` (`is_active`) USING BTREE,
KEY `idx_is_deleted` (`is_deleted`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='报销费用类型表';
-- 可按需为已有租户初始化默认类型:
-- INSERT INTO `yz_backend_reimbursement_expense_types`
-- (`tid`, `name`, `sort_order`)
-- VALUES
-- (1, '差旅费', 10),
-- (1, '交通费', 20),
-- (1, '餐饮费', 30),
-- (1, '办公费', 40),
-- (1, '通讯费', 50),
-- (1, '业务招待费', 60),
-- (1, '培训费', 70),
-- (1, '其他', 999);
+110
View File
@@ -0,0 +1,110 @@
-- 报销模块表结构
-- 执行前请确认当前数据库字符集为 utf8mb4
CREATE TABLE IF NOT EXISTS `yz_backend_approval_flows` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`flow_name` varchar(100) NOT NULL DEFAULT '' COMMENT '流程名称',
`step_order` tinyint(1) NOT NULL DEFAULT '0' COMMENT '步骤顺序(从1开始)',
`approver_role` varchar(50) NOT NULL DEFAULT '' COMMENT '审批角色(如: dept_manager, finance, gm)',
`approver_rule` varchar(200) NOT NULL DEFAULT '' COMMENT '审批人规则(如: 部门主管, 财务总监)',
`timeout_hours` int(11) NOT NULL DEFAULT '48' COMMENT '超时提醒时间(小时)',
`is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用:0-禁用 1-启用',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_flow_name` (`flow_name`) USING BTREE,
KEY `idx_step_order` (`step_order`) USING BTREE,
KEY `idx_is_active` (`is_active`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='审批流程配置表';
CREATE TABLE IF NOT EXISTS `yz_backend_reimbursements` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '申请人ID',
`department_id` int(11) unsigned DEFAULT '0' COMMENT '部门ID',
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '报销事由',
`total_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '总金额',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态:0-草稿 1-审批中 2-已通过 3-已驳回 4-已撤回 5-已打款',
`expense_type` varchar(50) NOT NULL DEFAULT '' COMMENT '费用类型主分类',
`apply_date` date NOT NULL COMMENT '申请日期',
`description` text COMMENT '备注说明',
`current_approver_id` int(11) unsigned DEFAULT '0' COMMENT '当前审批人ID',
`current_step` tinyint(1) NOT NULL DEFAULT '0' COMMENT '当前审批步骤(0:未提交)',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_user_id` (`user_id`) USING BTREE,
KEY `idx_department_id` (`department_id`) USING BTREE,
KEY `idx_status` (`status`) USING BTREE,
KEY `idx_expense_type` (`expense_type`) USING BTREE,
KEY `idx_apply_date` (`apply_date`) USING BTREE,
KEY `idx_current_approver_id` (`current_approver_id`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,
KEY `idx_update_time` (`update_time`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='报销单主表';
CREATE TABLE IF NOT EXISTS `yz_backend_reimbursement_items` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`reimbursement_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '关联报销单ID',
`expense_type` varchar(50) NOT NULL DEFAULT '' COMMENT '费用类型(具体子类)',
`expense_date` date NOT NULL COMMENT '费用发生日期',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '金额',
`quantity` int(11) NOT NULL DEFAULT '1' COMMENT '数量',
`unit_price` decimal(10,2) DEFAULT '0.00' COMMENT '单价',
`description` varchar(200) NOT NULL DEFAULT '' COMMENT '费用描述',
`receipt_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否有发票:0-否 1-是',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_reimbursement_id` (`reimbursement_id`) USING BTREE,
KEY `idx_expense_type` (`expense_type`) USING BTREE,
KEY `idx_expense_date` (`expense_date`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='报销费用明细表';
CREATE TABLE IF NOT EXISTS `yz_backend_approval_records` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`reimbursement_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '关联报销单ID',
`approver_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '审批人ID',
`approver_name` varchar(100) NOT NULL DEFAULT '' COMMENT '审批人姓名(冗余)',
`action` tinyint(1) NOT NULL DEFAULT '0' COMMENT '操作:1-提交 2-通过 3-驳回 4-转审 5-撤回',
`comment` text COMMENT '审批意见',
`step` tinyint(1) NOT NULL DEFAULT '0' COMMENT '审批步骤序号',
`prev_step` tinyint(1) NOT NULL DEFAULT '0' COMMENT '上一步骤序号',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_reimbursement_id` (`reimbursement_id`) USING BTREE,
KEY `idx_approver_id` (`approver_id`) USING BTREE,
KEY `idx_action` (`action`) USING BTREE,
KEY `idx_step` (`step`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='审批记录表';