增加报销模块功能
This commit is contained in:
Vendored
+2
@@ -65,6 +65,8 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
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']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -38,6 +38,11 @@ func Init(_ string) {
|
||||
new(BackendErpOrganization),
|
||||
new(BackendErpEmployee),
|
||||
new(BackendErpPosition),
|
||||
new(BackendApprovalFlow),
|
||||
new(BackendApprovalRecord),
|
||||
new(BackendReimbursement),
|
||||
new(BackendReimbursementItem),
|
||||
new(BackendReimbursementExpenseType),
|
||||
new(SystemMenu),
|
||||
new(AdminUser),
|
||||
new(AdminRole),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -152,6 +152,16 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/notebook/update/:id", &controllers.BackendNotebookController{}, "post:Update")
|
||||
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/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);
|
||||
@@ -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='审批记录表';
|
||||
Reference in New Issue
Block a user