优化报销管理增加挂靠
This commit is contained in:
1 parent
7b1c94b5ff
commit
ee49ef15b5
24 files changed
+1267
-70
No files matched your search
@@ -70,6 +70,16 @@ export function updateReimbursementStatus(id, data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 可挂靠发票:只返回「当前报销单」内上传、票面金额已维护且仍有余额的发票
|
||||
// reimbursement_id 必填;exclude_item_id 用于编辑明细时排除它自身的占用
|
||||
export function getAvailableInvoices(params) {
|
||||
return request({
|
||||
url: '/backend/reimbursements/invoices/available',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getReimbursementRecords(id) {
|
||||
return request({
|
||||
url: `/backend/reimbursements/${id}/records`,
|
||||
|
||||
+25
-19
@@ -14,7 +14,7 @@ import router from './router'
|
||||
import { loadAndAddDynamicRoutes } from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { redirectToAuthorize } from '@/utils/authClient'
|
||||
import { redirectToAuthorize, isAuthHandoff } from '@/utils/authClient'
|
||||
// import { initTheme } from './utils/theme'
|
||||
// 导入全局组件
|
||||
import UmoEditor from '@/views/components/UmoEditor.vue';
|
||||
@@ -40,26 +40,32 @@ app.use(VueViewer)
|
||||
// 初始化主题(必须在挂载前执行)
|
||||
// initTheme()
|
||||
|
||||
// 初始化时检查认证状态
|
||||
// 初始化时检查认证状态。
|
||||
// 认证交接页(/auth/callback、/app-login、/mp-login)正在兑换令牌,此时
|
||||
// localStorage 里还是旧 token。启动期请求带旧 token 打到后端会触发 401 自动
|
||||
// 恢复,进而清掉刚换到的新令牌并重新跳回登录页。交接页一律跳过启动期的
|
||||
// 登录态请求与自动跳转,由交接页写完令牌后整页重载。
|
||||
const authStore = useAuthStore()
|
||||
authStore.checkAuth()
|
||||
if (!isAuthHandoff()) {
|
||||
authStore.checkAuth()
|
||||
|
||||
// 如果用户已登录,在应用启动时加载动态路由
|
||||
if (authStore.isLoggedIn) {
|
||||
loadAndAddDynamicRoutes()
|
||||
.catch(err => {
|
||||
console.error('应用启动时加载动态路由失败:', err);
|
||||
})
|
||||
.then(() => {
|
||||
// 检查是否因为 token 无效而导致路由加载失败
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
// 本地登录已停用:直接回认证中心重新登录(不再跳 #/login)
|
||||
redirectToAuthorize().catch((err) => {
|
||||
console.error('跳转统一认证失败:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
// 如果用户已登录,在应用启动时加载动态路由
|
||||
if (authStore.isLoggedIn) {
|
||||
loadAndAddDynamicRoutes()
|
||||
.catch(err => {
|
||||
console.error('应用启动时加载动态路由失败:', err);
|
||||
})
|
||||
.then(() => {
|
||||
// 检查是否因为 token 无效而导致路由加载失败
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
// 本地登录已停用:直接回认证中心重新登录(不再跳 #/login)
|
||||
redirectToAuthorize().catch((err) => {
|
||||
console.error('跳转统一认证失败:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
@@ -344,13 +344,15 @@ function findFirstValidRoute(routes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 跳转统一认证中心;地址未配置时给出提示,避免白屏 */
|
||||
/** 跳转统一认证中心;地址未配置/检测到循环时给出提示,避免白屏或无限跳转 */
|
||||
async function gotoAuthorize() {
|
||||
try {
|
||||
await redirectToAuthorize();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
window.alert(e?.message || "统一认证跳转失败,请检查认证中心配置");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,14 +377,16 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
next({ path: "/home" });
|
||||
} else {
|
||||
await gotoAuthorize();
|
||||
const ok = await gotoAuthorize();
|
||||
if (!ok) next(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 未登录:统一走认证中心(本地登录已停用)
|
||||
if (!token) {
|
||||
await gotoAuthorize();
|
||||
const ok = await gotoAuthorize();
|
||||
if (!ok) next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -391,7 +395,14 @@ router.beforeEach(async (to, from, next) => {
|
||||
// 令牌失效时 ensureUserInfo 会清空登录态并返回 null,此时重新登录。
|
||||
const info = await ensureUserInfo();
|
||||
if (!info) {
|
||||
await gotoAuthorize();
|
||||
// 只有登录态确实被清空(确认令牌失效)才重新授权;userinfo 接口临时故障
|
||||
// 时保留 token 放行,避免「登录成功 → 接口抖动 → 回认证中心」的循环。
|
||||
if (localStorage.getItem("token")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const ok = await gotoAuthorize();
|
||||
if (!ok) next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { getCurrentUser } from '@/api/login'
|
||||
import { logoutSSO } from '@/utils/authClient'
|
||||
import { logoutSSO, isAuthHandoff } from '@/utils/authClient'
|
||||
|
||||
// 用户信息类型
|
||||
const defaultUser = {
|
||||
@@ -53,8 +53,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// 初始化时加载用户信息
|
||||
loadUserFromCache()
|
||||
// 若已登录但缺少租户名称,异步从后端补齐
|
||||
if (token.value) {
|
||||
// 若已登录但缺少租户名称,异步从后端补齐;
|
||||
// 认证交接页除外(此时 token 是旧的,请求会 401 并干扰换码)
|
||||
if (token.value && !isAuthHandoff()) {
|
||||
fetchCurrentUser()
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,51 @@ export function hasAuthBase() {
|
||||
return !!AUTH_BASE;
|
||||
}
|
||||
|
||||
// 认证「交接页」:正在用 code / ticket 兑换令牌。此时 localStorage 里还是
|
||||
// 上一次的旧 token,页面启动期的请求一旦 401,自动恢复逻辑会把刚换到的新
|
||||
// token 清掉并重新跳认证中心,形成「登录成功又回登录页」的循环。
|
||||
const HANDOFF_PATHS = ["/auth/callback", "/app-login", "/mp-login"];
|
||||
const REDIRECT_LOG_KEY = "auth_authorize_redirects";
|
||||
const LOOP_WINDOW_MS = 15000;
|
||||
const LOOP_MAX = 3;
|
||||
|
||||
export function isAuthHandoff() {
|
||||
const hash = window.location.hash || "";
|
||||
const hashPath = hash.startsWith("#") ? hash.slice(1).split("?")[0] : "";
|
||||
if (HANDOFF_PATHS.includes(hashPath)) return true;
|
||||
if (window.location.pathname === "/auth-callback.html") return true;
|
||||
// 兼容中转页把 code/ticket 放在 hash 之前的场景;只看 query,避免业务页面
|
||||
// 自身的 ?token= 等参数被误判成交接页。
|
||||
return /[?&](code|ticket)=/.test(window.location.search || "");
|
||||
}
|
||||
|
||||
// 短时间内反复发起授权说明出现了重定向环:停下并给出可诊断的错误,
|
||||
// 而不是让用户一直被弹回登录界面。
|
||||
function guardAuthorizeLoop() {
|
||||
const now = Date.now();
|
||||
let times = [];
|
||||
try {
|
||||
times = JSON.parse(sessionStorage.getItem(REDIRECT_LOG_KEY) || "[]");
|
||||
} catch (e) {
|
||||
times = [];
|
||||
}
|
||||
times = (Array.isArray(times) ? times : []).filter((t) => now - Number(t) < LOOP_WINDOW_MS);
|
||||
if (times.length >= LOOP_MAX) {
|
||||
throw new Error(
|
||||
"短时间内多次跳转统一登录,已停止自动跳转以避免循环;请检查认证中心与回跳白名单配置后重试"
|
||||
);
|
||||
}
|
||||
times.push(now);
|
||||
try {
|
||||
sessionStorage.setItem(REDIRECT_LOG_KEY, JSON.stringify(times));
|
||||
} catch (e) {
|
||||
// 隐私模式下 sessionStorage 不可写,忽略即可
|
||||
}
|
||||
}
|
||||
|
||||
// 计数按时间窗口自然过期,不在换码成功后清零:否则「换码成功 → 401 → 又跳
|
||||
// 认证中心」这类循环每次都会把计数抹掉,保护永远无法触发。
|
||||
|
||||
// ---------------------------------------------------------------- PKCE
|
||||
|
||||
function randomString(len = 64) {
|
||||
@@ -108,6 +153,7 @@ export async function redirectToAuthorize() {
|
||||
if (!AUTH_BASE) {
|
||||
throw new Error("统一认证地址未配置:请检查 VITE_AUTH_BASE 或 VITE_API_BASE_URL");
|
||||
}
|
||||
guardAuthorizeLoop();
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
sessionStorage.setItem("pkce_verifier", verifier);
|
||||
const state = randomString(24);
|
||||
@@ -132,6 +178,12 @@ export async function redirectToAuthorize() {
|
||||
|
||||
/** 处理认证中心回跳:用 code 换令牌 */
|
||||
export async function handleAuthorizeCallback(query = {}) {
|
||||
// 交接页加载期间,启动期请求是用旧 token 发出的。先清掉旧登录态,
|
||||
// 避免并发 401 续期拿旧 refresh_token 触发「重放吊销」把新会话一起废掉,
|
||||
// 也避免旧 userInfo 被误当成新会话的用户信息。
|
||||
clearTokens();
|
||||
localStorage.removeItem("userInfo");
|
||||
|
||||
const code = query.code;
|
||||
const state = query.state;
|
||||
const savedState = sessionStorage.getItem("oidc_state");
|
||||
@@ -274,10 +326,15 @@ export async function ensureUserInfo() {
|
||||
localStorage.setItem('userInfo', JSON.stringify(normalized));
|
||||
return normalized;
|
||||
} catch (e) {
|
||||
// 令牌无效(例如迁移前的旧令牌):清掉本地状态,让调用方重新登录
|
||||
clearTokens();
|
||||
localStorage.removeItem('userInfo');
|
||||
return null;
|
||||
// 只有确认令牌失效(401)才清空登录态。userinfo 接口网络抖动 / 404 / 5xx
|
||||
// 属于临时故障,一律清 token 会把刚登录成功的用户反复送回认证中心。
|
||||
if (e?.status === 401 || e?.isAuthError) {
|
||||
clearTokens();
|
||||
localStorage.removeItem('userInfo');
|
||||
return null;
|
||||
}
|
||||
console.warn('[auth] 获取用户信息失败,保留当前登录态:', e);
|
||||
return info && info.id ? info : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +355,11 @@ export async function fetchUserInfo() {
|
||||
// 续期失败:保持原响应,交给调用方按「未登录」处理
|
||||
}
|
||||
}
|
||||
if (!res.ok) throw new Error("获取用户信息失败");
|
||||
if (!res.ok) {
|
||||
const err = new Error(`获取用户信息失败(${res.status})`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { refreshAccessToken, clearTokens, redirectToAuthorize } from '@/utils/authClient';
|
||||
import { refreshAccessToken, clearTokens, redirectToAuthorize, isAuthHandoff } from '@/utils/authClient';
|
||||
|
||||
// 统一认证模式下 401 后正在跳转登录的标志,避免并发请求同时触发跳转造成死循环
|
||||
let ssoRedirecting = false;
|
||||
@@ -25,6 +25,9 @@ function authError(message) {
|
||||
|
||||
// 清空登录态并回认证中心重新登录(只跳一次,避免并发请求反复跳转)
|
||||
function redirectToLoginOnce() {
|
||||
// 交接页里的 401 来自旧 token,不代表刚换到的新登录态失效。
|
||||
// 在这里清 token / 跳认证中心会造成「登录成功后又回登录页」。
|
||||
if (isAuthHandoff()) return;
|
||||
clearTokens();
|
||||
localStorage.removeItem('userInfo');
|
||||
if (ssoRedirecting) return;
|
||||
@@ -42,6 +45,9 @@ function redirectToLoginOnce() {
|
||||
// 「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,只挡一次;
|
||||
// 否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
|
||||
async function recoverAuth(config, bizMsg) {
|
||||
// 交接页的 401 来自旧 token,不能用旧 refresh_token 续期(会触发重放吊销),
|
||||
// 也不能重放或自动跳转;换码完成后整页重载即可恢复正常请求。
|
||||
if (isAuthHandoff()) return Promise.reject(authError('正在完成登录,请稍候'));
|
||||
const latestToken = localStorage.getItem('token') || '';
|
||||
const sentToken = String(config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
|
||||
if (!config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
<el-descriptions-item label="申请日期">{{ detail.apply_date || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申请人">{{ detailApplicantName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="报销金额">¥ {{ money(detail.total_amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发票金额">{{ formatDetailReceiptTotal }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发票金额">
|
||||
<span title="本单实际上传票据的票面合计;挂靠只是把同一张票分给多条明细,不重复计入">{{ formatDetailReceiptTotal }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发票差额">
|
||||
<span :class="detailReceiptDiffClass">{{ formatDetailReceiptDiff }}</span>
|
||||
<span :class="detailReceiptDiffClass" title="发票金额 − 报销金额(挂靠不重复计入)">{{ formatDetailReceiptDiff }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注说明" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
@@ -53,6 +55,9 @@
|
||||
<template v-if="compareSummary.unmatchedCount > 0">
|
||||
,{{ compareSummary.unmatchedCount }} 张待填写
|
||||
</template>
|
||||
<template v-if="compareLinkedTotal > 0">
|
||||
,另有发票挂靠 ¥ {{ money(compareLinkedTotal) }}(同一张票不重复计入票据总额)
|
||||
</template>
|
||||
。识别结果可修改,确认后请保存。
|
||||
</p>
|
||||
<div class="table-scroll">
|
||||
@@ -98,9 +103,11 @@
|
||||
<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="90">
|
||||
<el-table-column prop="receipt_flag" label="发票" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ itemHasReceipt(row) ? '有' : '无' }}({{ getItemReceiptFiles(row).length }})
|
||||
<span :class="{ 'receipt-status-linked': itemReceiptStatus(row) === '挂靠' }">
|
||||
{{ itemReceiptStatus(row) }}({{ itemReceiptCount(row) }})
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发票金额" width="110">
|
||||
@@ -110,13 +117,36 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="发票差额" width="110">
|
||||
<template #default="{ row }">
|
||||
<span :class="itemReceiptDiffClass(row)">{{ formatItemReceiptDiff(row) }}</span>
|
||||
<span :class="itemReceiptDiffClass(row)" :title="itemReceiptDiffTip(row)">
|
||||
{{ formatItemReceiptDiff(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="176" fixed="right">
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="item-actions">
|
||||
<el-button
|
||||
link
|
||||
type="warning"
|
||||
:title="isEditable ? '挂靠有余额的发票' : '当前状态不可编辑费用明细'"
|
||||
aria-label="挂靠发票"
|
||||
:disabled="!isEditable"
|
||||
@click="openInvoicePicker(row)"
|
||||
>
|
||||
<i class="fa-solid fa-link"></i>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.invoice_file_id"
|
||||
link
|
||||
type="info"
|
||||
title="取消挂靠"
|
||||
aria-label="取消挂靠"
|
||||
:disabled="!isEditable"
|
||||
@click="clearInvoiceLink(row)"
|
||||
>
|
||||
<i class="fa-solid fa-link-slash"></i>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="itemHasReceipt(row)"
|
||||
link
|
||||
@@ -266,6 +296,14 @@
|
||||
@saved="handleItemsSaved"
|
||||
/>
|
||||
|
||||
<InvoicePicker
|
||||
v-model="pickerVisible"
|
||||
:reimbursement-id="detail?.id"
|
||||
:item-amount="pickerItemAmount"
|
||||
:exclude-item-id="pickerRow?.id || 0"
|
||||
@select="handleInvoiceSelect"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<div class="detail-toolbar">
|
||||
<el-button
|
||||
@@ -312,6 +350,7 @@ import {
|
||||
summarizeReceiptAmountRows
|
||||
} from '../utils/invoiceAmount'
|
||||
import ItemForm from './itemForm.vue'
|
||||
import InvoicePicker from './invoicePicker.vue'
|
||||
// 时间显示统一为:YYYY-MM-DD HH:mm:ss
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
|
||||
@@ -379,6 +418,9 @@ const submitting = ref(false)
|
||||
const withdrawing = ref(false)
|
||||
const manualSaving = ref(false)
|
||||
const itemVisible = ref(false)
|
||||
const pickerVisible = ref(false)
|
||||
const pickerRow = ref(null)
|
||||
const pickerItemAmount = ref(0)
|
||||
const batchUploading = ref(false)
|
||||
const invoiceFileInputRef = ref(null)
|
||||
const pendingUploadRow = ref(null)
|
||||
@@ -406,6 +448,14 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
// 本单挂靠发票的已使用额度:仅用于面板提示,不重复计入整单票据金额
|
||||
const compareLinkedTotal = computed(() =>
|
||||
(props.detail?.items || []).reduce(
|
||||
(sum, item) => sum + Number(item.invoice_use_amount || 0),
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
const compareSummary = computed(() => {
|
||||
if (!amountCompare.value?.details) {
|
||||
return {
|
||||
@@ -421,6 +471,7 @@ const compareSummary = computed(() => {
|
||||
(sum, item) => sum + Number(item.amount || 0),
|
||||
0
|
||||
)
|
||||
// 与顶部基础信息保持同一口径:挂靠不增加整单票据总额
|
||||
return summarizeReceiptAmountRows(amountCompare.value.details, claimTotal)
|
||||
})
|
||||
|
||||
@@ -571,6 +622,19 @@ const getItemReceiptFiles = item => {
|
||||
|
||||
const itemHasReceipt = item => getItemReceiptFiles(item).length > 0
|
||||
|
||||
// 「发票」列三态:有(本行上传发票)/ 挂靠(用本单其它明细上传的发票)/ 无。
|
||||
// 挂靠优先展示;数量为本行关联的发票凭证张数(上传张数 + 挂靠张数)。
|
||||
const itemLinkedInvoiceCount = item =>
|
||||
Number(item?.invoice_file_id || 0) > 0 && Number(item?.invoice_use_amount || 0) > 0 ? 1 : 0
|
||||
|
||||
const itemReceiptStatus = item => {
|
||||
if (itemLinkedInvoiceCount(item) > 0) return '挂靠'
|
||||
return itemHasReceipt(item) ? '有' : '无'
|
||||
}
|
||||
|
||||
const itemReceiptCount = item =>
|
||||
getItemReceiptFiles(item).length + itemLinkedInvoiceCount(item)
|
||||
|
||||
const getItemReceiptAmountTotal = item => {
|
||||
const amounts = getItemReceiptFiles(item)
|
||||
.map(file => file.amount)
|
||||
@@ -579,14 +643,49 @@ const getItemReceiptAmountTotal = item => {
|
||||
return amounts.reduce((sum, amount) => sum + Number(amount), 0)
|
||||
}
|
||||
|
||||
// 本条明细挂靠本单其它明细上传的发票时,本次分到的额度
|
||||
const getItemLinkedAmount = item => Number(item?.invoice_use_amount || 0)
|
||||
|
||||
// 按发票文件 ID 统计「被挂靠走」的额度。一张票的票面只在上传它的那条
|
||||
// 明细计入一次,挂靠出去的部分要从宿主明细划出,否则同一张票会被重复计算。
|
||||
const linkedOutByFile = computed(() => {
|
||||
const map = {}
|
||||
for (const item of props.detail?.items || []) {
|
||||
const fileId = Number(item?.invoice_file_id || 0)
|
||||
const used = getItemLinkedAmount(item)
|
||||
if (fileId > 0 && used > 0) {
|
||||
map[fileId] = (map[fileId] || 0) + used
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const getItemLinkedOutAmount = item =>
|
||||
getItemReceiptFiles(item).reduce(
|
||||
(sum, file) => sum + Number(linkedOutByFile.value[Number(file.file_id)] || 0),
|
||||
0
|
||||
)
|
||||
|
||||
// 本行可用发票金额 = 上传票面(扣除已划出给挂靠明细的部分)+ 本行挂靠分到的额度
|
||||
const getItemInvoiceAmount = item => {
|
||||
const uploaded = getItemReceiptAmountTotal(item)
|
||||
const linked = getItemLinkedAmount(item)
|
||||
if (uploaded == null && linked <= 0) return null
|
||||
const linkedOut = uploaded == null ? 0 : getItemLinkedOutAmount(item)
|
||||
const own = Math.max(0, (uploaded || 0) - linkedOut)
|
||||
return own + linked
|
||||
}
|
||||
|
||||
// 统一口径:本行发票金额 − 本行明细金额。所有明细、顶部整单完全一致,
|
||||
// 明细行差额之和就等于顶部整单差额。
|
||||
const getItemReceiptDiff = item => {
|
||||
const receiptTotal = getItemReceiptAmountTotal(item)
|
||||
if (receiptTotal == null) return null
|
||||
return receiptTotal - Number(item?.amount || 0)
|
||||
const invoiceTotal = getItemInvoiceAmount(item)
|
||||
if (invoiceTotal == null) return null
|
||||
return invoiceTotal - Number(item?.amount || 0)
|
||||
}
|
||||
|
||||
const formatItemReceiptTotal = item => {
|
||||
const total = getItemReceiptAmountTotal(item)
|
||||
const total = getItemInvoiceAmount(item)
|
||||
return total == null ? '-' : `¥ ${money(total)}`
|
||||
}
|
||||
|
||||
@@ -604,11 +703,27 @@ const itemReceiptDiffClass = item => {
|
||||
return diff > 0 ? 'receipt-diff-over' : 'receipt-diff-short'
|
||||
}
|
||||
|
||||
// 悬停说明差额构成:票面、划出的挂靠额度、分入的挂靠额度
|
||||
const itemReceiptDiffTip = item => {
|
||||
const uploaded = getItemReceiptAmountTotal(item)
|
||||
const linked = getItemLinkedAmount(item)
|
||||
if (uploaded == null && linked <= 0) return '本条明细暂无发票'
|
||||
const linkedOut = uploaded == null ? 0 : getItemLinkedOutAmount(item)
|
||||
const parts = []
|
||||
if (uploaded != null) parts.push(`上传票面 ¥${money(uploaded)}`)
|
||||
if (linkedOut > 0) parts.push(`划出挂靠 ¥${money(linkedOut)}`)
|
||||
if (linked > 0) parts.push(`挂靠分入 ¥${money(linked)}`)
|
||||
parts.push(`本行可用发票 ¥${money(getItemInvoiceAmount(item) || 0)} − 明细 ¥${money(item?.amount || 0)}`)
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
// 顶部整单 = 各明细可用发票金额之和。挂靠额度只是同一张票在明细之间划转,
|
||||
// 「划出」与「分入」相互抵消,因此总额等于实际上传票据的票面合计,不重复计算。
|
||||
const detailReceiptTotal = computed(() => {
|
||||
const items = props.detail?.items || []
|
||||
let hasAny = false
|
||||
const total = items.reduce((sum, item) => {
|
||||
const itemTotal = getItemReceiptAmountTotal(item)
|
||||
const itemTotal = getItemInvoiceAmount(item)
|
||||
if (itemTotal != null) {
|
||||
hasAny = true
|
||||
return sum + itemTotal
|
||||
@@ -729,6 +844,48 @@ const handleItemsSaved = reimbursementId => {
|
||||
emit('changed', reimbursementId)
|
||||
}
|
||||
|
||||
// 发票挂靠:对已有明细直接挂靠 / 更换 / 解挂,复用明细更新接口,
|
||||
// 请求里带上原发票附件字段,避免更新时把已上传的票据清掉。
|
||||
const saveInvoiceLink = async (row, fileId, useAmount) => {
|
||||
try {
|
||||
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: row.receipt_flag || 0,
|
||||
receipt_file_ids: row.receipt_file_ids || '',
|
||||
receipt_urls: row.receipt_urls || '',
|
||||
invoice_file_id: fileId,
|
||||
invoice_use_amount: useAmount
|
||||
})
|
||||
ElMessage.success(fileId ? '发票挂靠成功' : '已取消挂靠')
|
||||
emit('changed', props.detail?.id)
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '保存挂靠失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openInvoicePicker = row => {
|
||||
pickerRow.value = row
|
||||
pickerItemAmount.value = Number(row.amount || 0)
|
||||
pickerVisible.value = true
|
||||
}
|
||||
|
||||
const handleInvoiceSelect = selected => {
|
||||
const row = pickerRow.value
|
||||
if (!row) return
|
||||
saveInvoiceLink(row, selected.file_id, selected.use_amount)
|
||||
}
|
||||
|
||||
const clearInvoiceLink = row => {
|
||||
ElMessageBox.confirm('确认取消这条明细的发票挂靠吗?', '取消挂靠', { type: 'warning' })
|
||||
.then(() => saveInvoiceLink(row, 0, 0))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!props.detail?.id) return
|
||||
try {
|
||||
@@ -1146,6 +1303,11 @@ const money = value => Number(value || 0).toFixed(2)
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.receipt-status-linked {
|
||||
color: var(--el-color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择挂靠发票"
|
||||
width="780px"
|
||||
class="invoice-picker-dialog"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="picker-tip"
|
||||
title="一张发票可以分多次报销。这里只列出当前报销单内上传、且仍有余额的发票;本次使用金额不能超过发票余额,也不能超过本条费用明细金额。"
|
||||
/>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
border
|
||||
size="small"
|
||||
highlight-current-row
|
||||
max-height="320"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<el-table-column prop="name" label="发票文件" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="票面金额" width="110">
|
||||
<template #default="{ row }">¥ {{ money(row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用" width="100">
|
||||
<template #default="{ row }">¥ {{ money(row.used_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余可挂靠" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="remain">¥ {{ money(row.remain_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expense_date" label="上传日期" width="110" />
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleSelect(row)">
|
||||
{{ selectedId === row.file_id ? '已选' : '选择' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="当前报销单暂无有余额的发票:请先在本单费用明细上传发票并填写票面金额" :image-size="70" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="selected" class="picker-use">
|
||||
<span>本次使用金额</span>
|
||||
<el-input-number
|
||||
v-model="useAmount"
|
||||
:min="0.01"
|
||||
:max="maxUse"
|
||||
:precision="2"
|
||||
:step="10"
|
||||
controls-position="right"
|
||||
size="small"
|
||||
/>
|
||||
<span class="picker-hint">最多 ¥ {{ money(maxUse) }}</span>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selected" @click="confirm">确认挂靠</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAvailableInvoices } from '@/api/reimburse'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
// 当前报销单 ID:发票挂靠只允许在本单范围内进行
|
||||
reimbursementId: { type: [Number, String], default: 0 },
|
||||
// 本条费用明细金额,用于限制本次使用金额上限
|
||||
itemAmount: { type: Number, default: 0 },
|
||||
// 编辑已有明细时传入该明细 ID,排除它自身占用的额度
|
||||
excludeItemId: { type: [Number, String], default: 0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'select'])
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const selectedId = ref(0)
|
||||
const selected = ref(null)
|
||||
const useAmount = ref(0)
|
||||
|
||||
const money = value => Number(value || 0).toFixed(2)
|
||||
const maxUse = computed(() =>
|
||||
Number(
|
||||
Math.min(
|
||||
Number(props.itemAmount) || 0,
|
||||
Number(selected.value?.remain_amount) || 0
|
||||
).toFixed(2)
|
||||
)
|
||||
)
|
||||
|
||||
const load = async () => {
|
||||
if (!props.reimbursementId) {
|
||||
list.value = []
|
||||
ElMessage.warning('缺少报销单信息,无法加载可挂靠发票')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAvailableInvoices({
|
||||
reimbursement_id: props.reimbursementId,
|
||||
exclude_item_id: props.excludeItemId || undefined
|
||||
})
|
||||
const payload = res?.data ?? {}
|
||||
list.value = Array.isArray(payload.list)
|
||||
? payload.list
|
||||
: (Array.isArray(res?.list) ? res.list : [])
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '加载可挂靠发票失败')
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = row => {
|
||||
if (!row) return
|
||||
selectedId.value = row.file_id
|
||||
selected.value = row
|
||||
useAmount.value = maxUse.value
|
||||
}
|
||||
const handleCurrentChange = row => {
|
||||
if (row) handleSelect(row)
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
if (!selected.value) return
|
||||
const amount = Number(useAmount.value) || 0
|
||||
if (amount <= 0 || amount > maxUse.value + 0.001) {
|
||||
ElMessage.warning(`本次使用金额需在 0.01 ~ ${money(maxUse.value)} 之间`)
|
||||
return
|
||||
}
|
||||
emit('select', {
|
||||
file_id: selected.value.file_id,
|
||||
name: selected.value.name || '',
|
||||
remain_amount: selected.value.remain_amount,
|
||||
use_amount: Number(amount.toFixed(2))
|
||||
})
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async isVisible => {
|
||||
if (!isVisible) return
|
||||
selectedId.value = 0
|
||||
selected.value = null
|
||||
useAmount.value = 0
|
||||
await load()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.picker-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.remain {
|
||||
color: var(--el-color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.picker-use {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picker-hint {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -79,6 +79,25 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="invoice-link-row">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="Number(item.amount) <= 0"
|
||||
@click="openPicker(index)"
|
||||
>
|
||||
<i class="fa-solid fa-link"></i>
|
||||
{{ item.invoice_file_id ? '更换挂靠发票' : '挂靠有余额的发票' }}
|
||||
</el-button>
|
||||
<template v-if="item.invoice_file_id">
|
||||
<span class="invoice-link-info">
|
||||
{{ item.invoice_name || ('发票 #' + item.invoice_file_id) }}
|
||||
· 本次 ¥{{ money(item.invoice_use_amount) }}
|
||||
</span>
|
||||
<el-button link type="danger" @click="clearLink(index)">取消挂靠</el-button>
|
||||
</template>
|
||||
<span v-else class="invoice-link-tip">可选:一张发票分多次报销</span>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -91,6 +110,14 @@
|
||||
{{ editingItemId ? '保存' : `保存 ${items.length} 条` }}
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<InvoicePicker
|
||||
v-model="pickerVisible"
|
||||
:reimbursement-id="props.reimbursementId"
|
||||
:item-amount="pickerItemAmount"
|
||||
:exclude-item-id="props.editingItemId"
|
||||
@select="handleInvoiceSelect"
|
||||
/>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -98,6 +125,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { addReimbursementItem, updateReimbursementItem } from '@/api/reimburse'
|
||||
import InvoicePicker from './invoicePicker.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
@@ -115,6 +143,9 @@ const visible = computed({
|
||||
const saving = ref(false)
|
||||
const items = ref([])
|
||||
let rowKey = 0
|
||||
const pickerVisible = ref(false)
|
||||
const pickerIndex = ref(-1)
|
||||
const pickerItemAmount = ref(0)
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
const createRow = (source = {}) => ({
|
||||
@@ -124,9 +155,40 @@ const createRow = (source = {}) => ({
|
||||
amount: Number(source.amount || 0),
|
||||
description: source.description || '',
|
||||
unit_price: Number(source.unit_price || 0),
|
||||
receipt_urls: source.receipt_urls || ''
|
||||
receipt_urls: source.receipt_urls || '',
|
||||
invoice_file_id: Number(source.invoice_file_id || 0),
|
||||
invoice_name: source.invoice_name || '',
|
||||
invoice_use_amount: Number(source.invoice_use_amount || 0)
|
||||
})
|
||||
|
||||
const money = value => Number(value || 0).toFixed(2)
|
||||
|
||||
const openPicker = index => {
|
||||
const item = items.value[index]
|
||||
if (!item || Number(item.amount) <= 0) {
|
||||
ElMessage.warning('请先填写本条费用明细金额')
|
||||
return
|
||||
}
|
||||
pickerIndex.value = index
|
||||
pickerItemAmount.value = Number(item.amount)
|
||||
pickerVisible.value = true
|
||||
}
|
||||
|
||||
const handleInvoiceSelect = selected => {
|
||||
const index = pickerIndex.value
|
||||
if (index < 0 || !items.value[index]) return
|
||||
items.value[index].invoice_file_id = selected.file_id
|
||||
items.value[index].invoice_name = selected.name
|
||||
items.value[index].invoice_use_amount = selected.use_amount
|
||||
}
|
||||
|
||||
const clearLink = index => {
|
||||
if (!items.value[index]) return
|
||||
items.value[index].invoice_file_id = 0
|
||||
items.value[index].invoice_name = ''
|
||||
items.value[index].invoice_use_amount = 0
|
||||
}
|
||||
|
||||
const rules = {
|
||||
expense_type: [{ required: true, message: '请选择费用类型', trigger: 'change' }],
|
||||
expense_date: [{ required: true, message: '请选择发生日期', trigger: 'change' }],
|
||||
@@ -158,6 +220,15 @@ const submit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const invalidLinkIndex = items.value.findIndex(
|
||||
item => Number(item.invoice_file_id) > 0 &&
|
||||
Number(item.invoice_use_amount) > Number(item.amount) + 0.001
|
||||
)
|
||||
if (invalidLinkIndex !== -1) {
|
||||
ElMessage.warning(`第 ${invalidLinkIndex + 1} 条明细的挂靠金额超过明细金额,请重新选择挂靠发票`)
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.editingItemId) {
|
||||
@@ -171,7 +242,9 @@ const submit = async () => {
|
||||
description: item.description || '',
|
||||
receipt_flag: props.editingItem?.receipt_flag || 0,
|
||||
receipt_file_ids: props.editingItem?.receipt_file_ids || '',
|
||||
receipt_urls: props.editingItem?.receipt_urls || ''
|
||||
receipt_urls: props.editingItem?.receipt_urls || '',
|
||||
invoice_file_id: Number(item.invoice_file_id || 0),
|
||||
invoice_use_amount: Number(item.invoice_use_amount || 0)
|
||||
})
|
||||
} else {
|
||||
for (const item of items.value) {
|
||||
@@ -183,7 +256,9 @@ const submit = async () => {
|
||||
unit_price: item.unit_price || 0,
|
||||
description: item.description || '',
|
||||
receipt_flag: 0,
|
||||
receipt_urls: ''
|
||||
receipt_urls: '',
|
||||
invoice_file_id: Number(item.invoice_file_id || 0),
|
||||
invoice_use_amount: Number(item.invoice_use_amount || 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -220,6 +295,24 @@ watch(
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.invoice-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
padding-top: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.invoice-link-info {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.invoice-link-tip {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.item-list {
|
||||
max-height: 540px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<el-tabs v-model="activeTab" class="reimburse-tabs" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="全部" name="all" />
|
||||
<el-tab-pane label="未报销" name="unpaid" />
|
||||
<el-tab-pane label="已报销" name="paid" />
|
||||
</el-tabs>
|
||||
<div class="table-scroll">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
@@ -207,6 +212,8 @@ const statusOptions = [
|
||||
{ value: 4, label: "已撤回" },
|
||||
{ value: 5, label: "已打款" },
|
||||
];
|
||||
// 默认进入「未报销」:待处理的单据优先展示
|
||||
const activeTab = ref("unpaid");
|
||||
const filters = reactive({ keyword: "", status: undefined });
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
|
||||
const list = ref([]);
|
||||
@@ -251,7 +258,11 @@ const loadList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = responseData(
|
||||
await getReimbursementList({ ...filters, ...pagination }),
|
||||
await getReimbursementList({
|
||||
...filters,
|
||||
...pagination,
|
||||
reimbursed: activeTab.value === "all" ? undefined : activeTab.value,
|
||||
}),
|
||||
);
|
||||
list.value = data.list || [];
|
||||
pagination.total = Number(data.total || 0);
|
||||
@@ -262,9 +273,19 @@ const loadList = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 切换「全部 / 未报销 / 已报销」:回到第一页重新查询。
|
||||
// tab 与具体状态下拉语义有重叠,切换时清掉下拉,避免叠加出空结果。
|
||||
const handleTabChange = () => {
|
||||
filters.status = undefined;
|
||||
pagination.page = 1;
|
||||
loadList();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.keyword = "";
|
||||
filters.status = undefined;
|
||||
// 重置回到默认视图「未报销」
|
||||
activeTab.value = "unpaid";
|
||||
pagination.page = 1;
|
||||
loadList();
|
||||
};
|
||||
@@ -514,6 +535,17 @@ onBeforeUnmount(() => {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.reimburse-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
// 三个 pane 只作切换标签,表格共用一份,隐藏空内容区
|
||||
:deep(.el-tabs__content) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import request from "@/utils/request";
|
||||
import { setTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
|
||||
import { setTokens, clearTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
|
||||
|
||||
/**
|
||||
* 移动端免登录兑换页(/app-login)
|
||||
@@ -102,6 +102,9 @@ async function chooseTenant(t) {
|
||||
}
|
||||
|
||||
async function finishLogin(tokens) {
|
||||
// 清掉上一轮的旧登录态,避免旧 refresh_token 被并发续期逻辑误用
|
||||
clearTokens();
|
||||
localStorage.removeItem("userInfo");
|
||||
setTokens(tokens);
|
||||
await saveUserInfo();
|
||||
// 与 /auth/callback 一致:replaceState 改写 hash 后整页重载,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import request from "@/utils/request";
|
||||
import { setTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
|
||||
import { setTokens, clearTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
|
||||
|
||||
const error = ref("");
|
||||
const choosing = ref(false);
|
||||
@@ -73,6 +73,9 @@ async function finish() {
|
||||
}
|
||||
|
||||
async function finishLogin(tokens) {
|
||||
// 清掉上一轮的旧登录态,避免旧 refresh_token 被并发续期逻辑误用
|
||||
clearTokens();
|
||||
localStorage.removeItem("userInfo");
|
||||
setTokens(tokens);
|
||||
await saveUserInfo();
|
||||
// 与 /auth/callback 一致:replaceState 改写 hash 后整页重载,避免旧令牌请求 401
|
||||
|
||||
@@ -55,7 +55,7 @@ func (c *AppOaController) ExpenseList() {
|
||||
status, statusErr := c.GetInt("status")
|
||||
|
||||
data, code, msg := reimburseListData(claims, page, pageSize,
|
||||
strings.TrimSpace(c.GetString("keyword")), statusErr == nil, status)
|
||||
strings.TrimSpace(c.GetString("keyword")), statusErr == nil, status, "")
|
||||
if msg != "" {
|
||||
c.oaErr(code, code, msg)
|
||||
return
|
||||
|
||||
@@ -35,14 +35,28 @@ func (c *AuthLoginController) LoginPage() {
|
||||
step := c.GetString("step")
|
||||
errMsg := c.GetString("error")
|
||||
|
||||
// 已登录且已选企业:直接放行到 authorize,无需再输密码
|
||||
// 已登录且已选企业:直接放行,无需再输密码。
|
||||
// 即使 redirect 丢失也要放行到业务站首页,否则会一直停留在登录页,
|
||||
// 表现为「登录后还是跳回登录界面」。
|
||||
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||
if sid != "" && step != "tenant" {
|
||||
if session, err := authsvc.GetSession(sid); err == nil && session.Tid != authsvc.PendingTenantID {
|
||||
if back := decodeRedirect(redirect); back != "" {
|
||||
if session, err := authsvc.GetSession(sid); err == nil {
|
||||
if session.Tid == authsvc.PendingTenantID {
|
||||
// 已登录但还没选企业:直接带到企业选择步骤,
|
||||
// 不要再渲染一次登录表单让用户重新登录。
|
||||
target := "/auth/login?step=tenant&client_id=" + url.QueryEscape(clientID) +
|
||||
"&redirect=" + url.QueryEscape(redirect)
|
||||
c.Redirect(target, 302)
|
||||
return
|
||||
}
|
||||
if back := safeBackURL(clientID, redirect); back != "" {
|
||||
c.Redirect(back, 302)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 会话已被吊销/过期:清掉失效 Cookie,避免后续请求继续带上它
|
||||
clearSessionCookieForCtx(c.Ctx)
|
||||
sid = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,12 +69,17 @@ func (c *AuthLoginController) LoginPage() {
|
||||
// 只有一家企业直接进入,无需展示选择页
|
||||
if len(tenants) == 1 && step == "tenant" {
|
||||
if _, _, err := authsvc.ChooseTenant(sid, tenants[0].Tid, clientID); err == nil {
|
||||
if back := decodeRedirect(redirect); back != "" {
|
||||
if back := safeBackURL(clientID, redirect); back != "" {
|
||||
c.Redirect(back, 302)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if step == "tenant" {
|
||||
// 选企业时会话已失效:不要渲染一个空的企业列表,退回登录页重新登录
|
||||
clearSessionCookieForCtx(c.Ctx)
|
||||
c.Redirect("/auth/login?client_id="+url.QueryEscape(clientID), 302)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +187,7 @@ func (c *AuthLoginController) LoginSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
back := decodeRedirect(req.Redirect)
|
||||
back := safeBackURL(req.ClientID, req.Redirect)
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "登录成功",
|
||||
@@ -236,7 +255,7 @@ func (c *AuthLoginController) SwitchTenant() {
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "切换成功",
|
||||
"redirect": decodeRedirect(req.Redirect),
|
||||
"redirect": safeBackURL(req.ClientID, req.Redirect),
|
||||
"data": map[string]interface{}{"tokens": tokens, "identity": profile},
|
||||
})
|
||||
}
|
||||
@@ -360,6 +379,29 @@ func (c *AuthLoginController) LogoutAction() {
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
|
||||
}
|
||||
|
||||
// ClearSiteData 清除当前站点在浏览器中的全部缓存数据
|
||||
// POST /auth/clear-site-data
|
||||
//
|
||||
// 登录页「清除缓存」按钮调用。前端 JS 已清理同源的 Web Storage / Cache
|
||||
// Storage / IndexedDB / Service Worker,但 yz_sid 是 HttpOnly Cookie,前端
|
||||
// 读不到也删不掉,因此这里:
|
||||
// - 用 Set-Cookie 直接删除会话 Cookie(兼容所有浏览器);
|
||||
// - 返回 Clear-Site-Data 响应头,让浏览器在安全上下文(HTTPS / localhost)
|
||||
// 下把本 origin 的 cookies / storage / HTTP cache 一并清空。
|
||||
//
|
||||
// Clear-Site-Data 严格限定在响应所属 origin,不会影响其它站点。
|
||||
func (c *AuthLoginController) ClearSiteData() {
|
||||
// Cookie 被清后服务端会话将无人使用,同步吊销,避免留下仍有效的孤儿会话
|
||||
// 占用「1 号 1 机」名额。
|
||||
if sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)); sid != "" {
|
||||
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
|
||||
}
|
||||
clearSessionCookieForCtx(c.Ctx)
|
||||
c.Ctx.Output.Header("Clear-Site-Data", `"cache", "cookies", "storage"`)
|
||||
c.Ctx.Output.Header("Cache-Control", "no-store")
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已清除"})
|
||||
}
|
||||
|
||||
// VerifyConfig 登录验证配置(替代 /backend/login/getOpenVerify)
|
||||
// GET /auth/verify-config?tid=
|
||||
//
|
||||
@@ -391,6 +433,34 @@ func (c *AuthLoginController) VerifyConfig() {
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
|
||||
// safeBackURL 计算登录成功后的回跳地址。
|
||||
//
|
||||
// 优先使用业务端传来的 authorize 地址(base64url 编码);若链路中 redirect
|
||||
// 丢失(直接打开登录页、单点登出后再次登录、选企业时参数未透传等),则退回
|
||||
// 该应用已登记回跳地址所在的站点首页。业务站没有令牌时会自动重新发起 OIDC,
|
||||
// 认证中心此时已有会话可静默签码完成登录,不会卡在登录页形成循环。
|
||||
func safeBackURL(clientID, redirect string) string {
|
||||
if back := decodeRedirect(redirect); back != "" {
|
||||
return back
|
||||
}
|
||||
client, err := findClient(strings.TrimSpace(clientID))
|
||||
if err != nil || client.RedirectURIs == nil {
|
||||
return ""
|
||||
}
|
||||
var list []string
|
||||
if err := json.Unmarshal([]byte(*client.RedirectURIs), &list); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, item := range list {
|
||||
u, err := url.Parse(strings.TrimSpace(item))
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
continue
|
||||
}
|
||||
return u.Scheme + "://" + u.Host + "/"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeRedirect(encoded string) string {
|
||||
if encoded == "" {
|
||||
return ""
|
||||
|
||||
@@ -368,17 +368,17 @@ func authorizePath(c *AuthOidcController) string {
|
||||
return "/auth/authorize"
|
||||
}
|
||||
|
||||
// clearSessionCookie 清除认证中心会话 Cookie
|
||||
// clearSessionCookie 清除认证中心会话 Cookie。
|
||||
// 与写入复用同一实现:http 环境写入的 Cookie 不带 Secure,若清除时硬编码
|
||||
// Secure,浏览器会拒绝删除,失效的 yz_sid 一直残留,authorize 每次都判定
|
||||
// 未登录并把用户送回登录页。
|
||||
func clearSessionCookie(c *AuthOidcController) {
|
||||
c.Ctx.Output.Header("Set-Cookie",
|
||||
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
|
||||
clearSessionCookieForCtx(c.Ctx)
|
||||
}
|
||||
|
||||
// setSessionCookie 写入认证中心会话 Cookie
|
||||
func setSessionCookie(c *AuthOidcController, sid string) {
|
||||
c.Ctx.Output.Header("Set-Cookie",
|
||||
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
|
||||
sessionCookieName, sid, sessionCookieTTL))
|
||||
setSessionCookieForCtx(c.Ctx, sid)
|
||||
}
|
||||
|
||||
func bearerToken(c *AuthOidcController) string {
|
||||
@@ -526,7 +526,10 @@ func exchangeCode(code, verifier, clientID string) (*authsvc.TokenPair, *jwtutil
|
||||
Filter("code_hash", stored.CodeHash).
|
||||
Update(map[string]interface{}{"used": 1})
|
||||
|
||||
session, err := authsvc.CreateSession(authsvc.SessionInfo{
|
||||
// 复用登录阶段建立的会话,切勿在此新建:新建会话会触发「1 号 1 机」
|
||||
// 互踢,把浏览器 yz_sid Cookie 对应的会话吊销,导致下一次回认证中心
|
||||
// 又看到登录页(登录成功后反复跳回登录界面)。
|
||||
session, err := authsvc.ReuseSession(authsvc.SessionInfo{
|
||||
IdentityID: stored.IdentityID,
|
||||
Tid: stored.Tid,
|
||||
ClientID: clientID,
|
||||
|
||||
@@ -268,6 +268,12 @@ func (c *AuthThirdController) Callback() {
|
||||
c.Redirect("/auth/authorize?"+v.Encode(), 302)
|
||||
return
|
||||
}
|
||||
// 缺少 OIDC 参数时也要把已登录用户送回业务站,由其重新发起授权流程;
|
||||
// 直接回登录页会让「登录成功」看起来又变成了未登录。
|
||||
if back := safeBackURL(st.ClientID, ""); back != "" {
|
||||
c.Redirect(back, 302)
|
||||
return
|
||||
}
|
||||
c.Redirect("/auth/login", 302)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ type reimbursementItemPayload struct {
|
||||
ReceiptFileIDs string `json:"receipt_file_ids"`
|
||||
ReceiptURLs string `json:"receipt_urls"`
|
||||
ReceiptAmounts *string `json:"receipt_amounts,omitempty"`
|
||||
// 发票挂靠:用指针区分「未传」与「传 0」。上传发票、保存票面金额等调用
|
||||
// 不带这两个字段,此时必须保留明细原有的挂靠关系;只有显式传 0 才是解挂。
|
||||
InvoiceFileID *uint64 `json:"invoice_file_id"`
|
||||
InvoiceUseAmount *float64 `json:"invoice_use_amount"`
|
||||
}
|
||||
|
||||
type reimbursementPayload struct {
|
||||
@@ -294,6 +298,15 @@ func applyReceiptToItem(item *models.BackendReimbursementItem, payload reimburse
|
||||
}
|
||||
|
||||
func reimbursementItemDTO(item models.BackendReimbursementItem, scheme, host string) map[string]interface{} {
|
||||
invoiceName := ""
|
||||
if item.InvoiceFileID > 0 {
|
||||
var f models.SystemFile
|
||||
if err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||
Filter("id", item.InvoiceFileID).
|
||||
Filter("delete_time__isnull", true).One(&f, "name"); err == nil {
|
||||
invoiceName = f.Name
|
||||
}
|
||||
}
|
||||
receiptFiles := resolveReceiptFiles(scheme, host, item)
|
||||
receiptURLs := normalizeReceiptURLs(scheme, host, item.ReceiptURLs)
|
||||
receiptFlag := item.ReceiptFlag
|
||||
@@ -315,6 +328,9 @@ func reimbursementItemDTO(item models.BackendReimbursementItem, scheme, host str
|
||||
"receipt_file_ids": item.ReceiptFileIDs,
|
||||
"receipt_urls": receiptURLs, "receipt_amounts": item.ReceiptAmounts,
|
||||
"receipt_files": receiptFiles,
|
||||
"invoice_file_id": item.InvoiceFileID,
|
||||
"invoice_use_amount": item.InvoiceUseAmount,
|
||||
"invoice_name": invoiceName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,8 +363,10 @@ func (c *BackendReimburseController) List() {
|
||||
pageSize, _ := c.GetInt("pageSize", 10)
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
status, statusErr := c.GetInt("status")
|
||||
// 列表 tab:all(不传)/ unpaid(未报销)/ paid(已报销)
|
||||
reimbursed := strings.TrimSpace(c.GetString("reimbursed"))
|
||||
|
||||
data, code, msg := reimburseListData(claims, page, pageSize, keyword, statusErr == nil, status)
|
||||
data, code, msg := reimburseListData(claims, page, pageSize, keyword, statusErr == nil, status, reimbursed)
|
||||
if msg != "" {
|
||||
c.reply(code, msg, nil)
|
||||
return
|
||||
@@ -357,8 +375,9 @@ func (c *BackendReimburseController) List() {
|
||||
}
|
||||
|
||||
// reimburseListData 报销单列表共享逻辑:Web 后台与移动端(/api/oa/expense/list)共用。
|
||||
// reimbursed 供 Web 列表的「全部/未报销/已报销」tab 使用,移动端传空串保持原行为。
|
||||
// 返回 (数据, HTTP 状态码, 错误信息),错误信息为空串表示成功。
|
||||
func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword string, hasStatus bool, status int) (map[string]interface{}, int, string) {
|
||||
func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword string, hasStatus bool, status int, reimbursed string) (map[string]interface{}, int, string) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -401,6 +420,20 @@ func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword strin
|
||||
if keyword != "" {
|
||||
qs = qs.Filter("description__icontains", keyword)
|
||||
}
|
||||
// 「全部 / 未报销 / 已报销」:已通过(2) + 已打款(5) 算已报销,
|
||||
// 草稿/审批中/已驳回/已撤回算未报销,口径与 dashboard 的 paid/unpaid 一致。
|
||||
// 用 status__in 明确枚举,避免 Exclude 对 NULL 或新增状态的歧义;可与 status 精确筛选叠加。
|
||||
reimbursedStatuses := []int8{reimbursementStatusApproved, reimbursementStatusPaid}
|
||||
unreimbursedStatuses := []int8{
|
||||
reimbursementStatusDraft, reimbursementStatusApproving,
|
||||
reimbursementStatusRejected, reimbursementStatusWithdrawn,
|
||||
}
|
||||
switch reimbursed {
|
||||
case "paid":
|
||||
qs = qs.Filter("status__in", reimbursedStatuses)
|
||||
case "unpaid":
|
||||
qs = qs.Filter("status__in", unreimbursedStatuses)
|
||||
}
|
||||
if hasStatus {
|
||||
qs = qs.Filter("status", status)
|
||||
}
|
||||
@@ -697,6 +730,232 @@ func validateReimbursementItem(item reimbursementItemPayload) (time.Time, error)
|
||||
return date, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 发票挂靠
|
||||
|
||||
// invoicePoolEntry 发票池中的一张票(按附件文件 ID 聚合)。
|
||||
type invoicePoolEntry struct {
|
||||
FileID uint64
|
||||
Name string
|
||||
URL string
|
||||
ExpenseDate string
|
||||
Amount float64 // 票面金额(receipt_amounts)
|
||||
UsedAmount float64 // 已被各明细占用的金额
|
||||
SourceItemID uint64
|
||||
SourceReimbursementID uint64
|
||||
}
|
||||
|
||||
// collectInvoicePool 聚合「当前报销单」内的发票及余额。
|
||||
//
|
||||
// 边界:只能挂靠本报销单里上传过的发票,不跨单据、不跨用户。
|
||||
// 口径:
|
||||
// - 票面金额取「上传该票的明细行」receipt_amounts 对应值(OCR 或人工填写);
|
||||
// - 上传发票的明细按「明细金额 / 该行发票张数」自动占用额度,保证一条明细
|
||||
// 上传多张票时不会被重复占用;
|
||||
// - 本单其它明细通过 invoice_file_id + invoice_use_amount 显式挂靠,累加占用;
|
||||
// - 余额 = 票面 - 占用。明细删除(软删)后占用自动释放,无需额外回滚。
|
||||
//
|
||||
// excludeItemID 用于编辑某条明细时排除它自身的占用,得到「本单其它明细用完后
|
||||
// 还剩多少可挂靠」。
|
||||
func collectInvoicePool(claims *jwtutil.Claims, reimbursementID, excludeItemID uint64) ([]invoicePoolEntry, error) {
|
||||
if reimbursementID == 0 {
|
||||
return []invoicePoolEntry{}, nil
|
||||
}
|
||||
// 归属校验:只允许访问本人的报销单(tid + user_id + 未删除)
|
||||
if _, err := findOwnedReimbursement(claims, reimbursementID); err != nil {
|
||||
return nil, fmt.Errorf("报销单不存在")
|
||||
}
|
||||
var items []models.BackendReimbursementItem
|
||||
_, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
||||
Filter("reimbursement_id", reimbursementID).
|
||||
Filter("is_deleted", 0).
|
||||
All(&items)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pool := map[uint64]*invoicePoolEntry{}
|
||||
for i := range items {
|
||||
row := &items[i]
|
||||
if row.ID == excludeItemID {
|
||||
continue
|
||||
}
|
||||
ids := parseReceiptFileIDs(row.ReceiptFileIDs)
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
amountParts := parseReceiptAmounts(row.ReceiptAmounts)
|
||||
urlParts := strings.Split(row.ReceiptURLs, ",")
|
||||
share := row.Amount / float64(len(ids))
|
||||
for idx, id := range ids {
|
||||
entry := pool[id]
|
||||
if entry == nil {
|
||||
entry = &invoicePoolEntry{
|
||||
FileID: id, SourceItemID: row.ID,
|
||||
SourceReimbursementID: row.ReimbursementID,
|
||||
ExpenseDate: row.ExpenseDate.Format("2006-01-02"),
|
||||
}
|
||||
if idx < len(urlParts) {
|
||||
entry.URL = strings.TrimSpace(urlParts[idx])
|
||||
}
|
||||
if idx < len(amountParts) && amountParts[idx] != "" {
|
||||
entry.Amount, _ = strconv.ParseFloat(amountParts[idx], 64)
|
||||
}
|
||||
pool[id] = entry
|
||||
}
|
||||
entry.UsedAmount += share
|
||||
}
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
row := &items[i]
|
||||
if row.ID == excludeItemID || row.InvoiceFileID == 0 || row.InvoiceUseAmount <= 0 {
|
||||
continue
|
||||
}
|
||||
// 自己上传的票已按明细金额算过宿主占用,不重复累计
|
||||
selfUploaded := false
|
||||
for _, id := range parseReceiptFileIDs(row.ReceiptFileIDs) {
|
||||
if id == row.InvoiceFileID {
|
||||
selfUploaded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if selfUploaded {
|
||||
continue
|
||||
}
|
||||
if entry := pool[row.InvoiceFileID]; entry != nil {
|
||||
entry.UsedAmount += row.InvoiceUseAmount
|
||||
}
|
||||
}
|
||||
|
||||
// 一次性补齐文件名,避免逐票查询
|
||||
ids := make([]uint64, 0, len(pool))
|
||||
for id := range pool {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
var files []models.SystemFile
|
||||
_, _ = models.Orm.QueryTable(new(models.SystemFile)).
|
||||
Filter("id__in", ids).Filter("delete_time__isnull", true).All(&files, "id", "name")
|
||||
names := map[uint64]string{}
|
||||
for i := range files {
|
||||
names[files[i].ID] = files[i].Name
|
||||
}
|
||||
for id, entry := range pool {
|
||||
entry.Name = names[id]
|
||||
if entry.Name == "" && entry.URL != "" {
|
||||
entry.Name = path.Base(strings.Split(entry.URL, "?")[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]invoicePoolEntry, 0, len(pool))
|
||||
for _, entry := range pool {
|
||||
result = append(result, *entry)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].ExpenseDate == result[j].ExpenseDate {
|
||||
return result[i].FileID > result[j].FileID
|
||||
}
|
||||
return result[i].ExpenseDate > result[j].ExpenseDate
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validateInvoiceLink 校验挂靠是否合法:只能挂靠当前报销单里上传的发票,
|
||||
// 且票面金额已维护、余额充足、本次使用额不超过明细金额。
|
||||
func validateInvoiceLink(claims *jwtutil.Claims, payload reimbursementItemPayload, reimbursementID, excludeItemID uint64) (int, string) {
|
||||
// 未带挂靠字段(上传发票 / 保存票面金额等)表示不改挂靠关系,直接放行
|
||||
if payload.InvoiceFileID == nil {
|
||||
return 200, ""
|
||||
}
|
||||
fileID := *payload.InvoiceFileID
|
||||
if fileID == 0 {
|
||||
return 200, ""
|
||||
}
|
||||
useAmount := float64(0)
|
||||
if payload.InvoiceUseAmount != nil {
|
||||
useAmount = *payload.InvoiceUseAmount
|
||||
}
|
||||
if useAmount <= 0 {
|
||||
return 400, "挂靠金额必须大于0"
|
||||
}
|
||||
if useAmount > payload.Amount+0.005 {
|
||||
return 400, "挂靠金额不能超过本条费用明细金额"
|
||||
}
|
||||
// 本行已经上传这张票时无需再挂靠:宿主明细本身已按明细金额占用额度
|
||||
for _, id := range parseReceiptFileIDs(payload.ReceiptFileIDs) {
|
||||
if id == fileID {
|
||||
return 400, "本条明细已上传该发票,无需重复挂靠"
|
||||
}
|
||||
}
|
||||
// 发票池只取本报销单的明细,因此天然挡住跨单据 / 跨用户挂靠
|
||||
pool, err := collectInvoicePool(claims, reimbursementID, excludeItemID)
|
||||
if err != nil {
|
||||
return 400, "报销单不存在或无法读取发票信息"
|
||||
}
|
||||
for _, entry := range pool {
|
||||
if entry.FileID != fileID {
|
||||
continue
|
||||
}
|
||||
if entry.Amount <= 0 {
|
||||
return 400, "所选发票未维护票面金额,请先在「核对发票金额」中填写"
|
||||
}
|
||||
remain := entry.Amount - entry.UsedAmount
|
||||
if useAmount > remain+0.005 {
|
||||
return 400, fmt.Sprintf("发票余额不足,当前剩余 ¥%.2f", remain)
|
||||
}
|
||||
return 200, ""
|
||||
}
|
||||
return 400, "该发票不属于当前报销单,或已无余额;请取消挂靠后选择本单上传的发票"
|
||||
}
|
||||
|
||||
// AvailableInvoices GET /backend/reimbursements/invoices/available?reimbursement_id=
|
||||
// 返回「当前报销单」内仍有余额、可供挂靠的发票;其它报销单的发票一律不可见。
|
||||
func (c *BackendReimburseController) AvailableInvoices() {
|
||||
claims, ok := c.claims()
|
||||
if !ok {
|
||||
c.reply(401, "未登录或无权限", nil)
|
||||
return
|
||||
}
|
||||
reimbursementID, err := strconv.ParseUint(strings.TrimSpace(c.GetString("reimbursement_id")), 10, 64)
|
||||
if err != nil || reimbursementID == 0 {
|
||||
c.reply(400, "缺少报销单ID", nil)
|
||||
return
|
||||
}
|
||||
excludeID, _ := strconv.ParseUint(strings.TrimSpace(c.GetString("exclude_item_id")), 10, 64)
|
||||
keyword := strings.ToLower(strings.TrimSpace(c.GetString("keyword")))
|
||||
pool, err := collectInvoicePool(claims, reimbursementID, excludeID)
|
||||
if err != nil {
|
||||
c.reply(400, "报销单不存在或无法读取发票信息", nil)
|
||||
return
|
||||
}
|
||||
scheme, host := services.PublicRequestBaseURL(&c.Controller)
|
||||
list := make([]map[string]interface{}, 0, len(pool))
|
||||
for _, entry := range pool {
|
||||
remain := entry.Amount - entry.UsedAmount
|
||||
if entry.Amount <= 0 || remain <= 0.005 {
|
||||
continue
|
||||
}
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(entry.Name), keyword) {
|
||||
continue
|
||||
}
|
||||
list = append(list, map[string]interface{}{
|
||||
"file_id": entry.FileID,
|
||||
"name": entry.Name,
|
||||
"url": services.ResolvePublicURL(scheme, host, entry.URL),
|
||||
"amount": entry.Amount,
|
||||
"used_amount": entry.UsedAmount,
|
||||
"remain_amount": remain,
|
||||
"source_item_id": entry.SourceItemID,
|
||||
"source_reimbursement_id": entry.SourceReimbursementID,
|
||||
"expense_date": entry.ExpenseDate,
|
||||
})
|
||||
}
|
||||
c.reply(200, "success", map[string]interface{}{"list": list})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 发票挂靠结束
|
||||
|
||||
// canEditReimbursement 可编辑状态:草稿、已驳回、已撤回。
|
||||
// 审批中需先撤回;已通过/已打款不可编辑,保证审批结果与打款金额不被改动。
|
||||
func canEditReimbursement(status int8) bool {
|
||||
@@ -835,16 +1094,31 @@ func reimburseAddItemCore(claims *jwtutil.Claims, reimbursementID uint64, payloa
|
||||
if err != nil {
|
||||
return nil, 400, err.Error()
|
||||
}
|
||||
if code, msg := validateInvoiceLink(claims, payload, reimbursementID, 0); msg != "" {
|
||||
return nil, code, msg
|
||||
}
|
||||
uid := uint64(claims.UserID)
|
||||
quantity := payload.Quantity
|
||||
if quantity <= 0 {
|
||||
quantity = 1
|
||||
}
|
||||
fileID := uint64(0)
|
||||
useAmount := float64(0)
|
||||
if payload.InvoiceFileID != nil {
|
||||
fileID = *payload.InvoiceFileID
|
||||
}
|
||||
if payload.InvoiceUseAmount != nil {
|
||||
useAmount = *payload.InvoiceUseAmount
|
||||
}
|
||||
if fileID == 0 {
|
||||
useAmount = 0
|
||||
}
|
||||
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),
|
||||
InvoiceFileID: fileID, InvoiceUseAmount: useAmount,
|
||||
}
|
||||
applyReceiptToItem(item, payload, scheme, host)
|
||||
if _, err := models.Orm.Insert(item); err != nil {
|
||||
@@ -904,10 +1178,27 @@ func reimburseUpdateItemCore(claims *jwtutil.Claims, reimbursementID, itemID uin
|
||||
if err != nil {
|
||||
return nil, 400, err.Error()
|
||||
}
|
||||
if code, msg := validateInvoiceLink(claims, payload, reimbursementID, itemID); msg != "" {
|
||||
return nil, code, msg
|
||||
}
|
||||
quantity := payload.Quantity
|
||||
if quantity <= 0 {
|
||||
quantity = 1
|
||||
}
|
||||
// 只有显式携带挂靠字段才改动挂靠关系;上传发票 / 保存票面金额等请求
|
||||
// 不带这两个字段,必须保留原有挂靠,避免被清零。
|
||||
if payload.InvoiceFileID != nil {
|
||||
fileID := *payload.InvoiceFileID
|
||||
useAmount := float64(0)
|
||||
if payload.InvoiceUseAmount != nil {
|
||||
useAmount = *payload.InvoiceUseAmount
|
||||
}
|
||||
if fileID == 0 {
|
||||
useAmount = 0
|
||||
}
|
||||
item.InvoiceFileID = fileID
|
||||
item.InvoiceUseAmount = useAmount
|
||||
}
|
||||
item.ExpenseType = strings.TrimSpace(payload.ExpenseType)
|
||||
item.ExpenseDate = date
|
||||
item.Amount = payload.Amount
|
||||
@@ -1300,6 +1591,13 @@ const (
|
||||
reimbursementStatusPaid int8 = 5 // 已打款
|
||||
)
|
||||
|
||||
// isReimbursedStatus 判断报销单是否属于「已报销」。
|
||||
// 业务口径:审批已通过(2) 与已打款(5) 均算已报销;
|
||||
// 草稿(0) / 审批中(1) / 已驳回(3) / 已撤回(4) 均为未报销。
|
||||
func isReimbursedStatus(status int8) bool {
|
||||
return status == reimbursementStatusApproved || status == reimbursementStatusPaid
|
||||
}
|
||||
|
||||
// UpdateStatus POST /backend/reimbursements/:id/status — 报销人确认款项到账(置为「已打款」)。
|
||||
// 其它状态均由审批引擎/流程动作推进,不再支持手工变更。
|
||||
func (c *BackendReimburseController) UpdateStatus() {
|
||||
@@ -1729,7 +2027,7 @@ func (c *BackendReimburseController) Dashboard() {
|
||||
addTo := func(stat map[string]interface{}, row *models.BackendReimbursement) {
|
||||
stat["total"] = stat["total"].(float64) + row.TotalAmount
|
||||
stat["count"] = stat["count"].(int) + 1
|
||||
if row.Status == 5 { // 已打款视为已报销
|
||||
if isReimbursedStatus(row.Status) { // 已通过 / 已打款均视为已报销
|
||||
stat["paid"] = stat["paid"].(float64) + row.TotalAmount
|
||||
} else {
|
||||
stat["unpaid"] = stat["unpaid"].(float64) + row.TotalAmount
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- =============================================================
|
||||
-- OA 报销:费用明细支持「发票挂靠」(已建表环境执行)
|
||||
--
|
||||
-- 背景:一张发票(例如 200 元油票/邮费票)常常需要分多次报销才能报满。
|
||||
-- 现在每条费用明细可以挂靠一张「仍有余额的发票」,并记录本次使用金额。
|
||||
--
|
||||
-- 设计:不新建发票表,发票本体仍是明细行 receipt_file_ids 指向的附件。
|
||||
-- 票面金额取 receipt_amounts(OCR 识别或人工填写),余额由后端实时聚合:
|
||||
-- 余额 = 票面金额 - 上传该票的明细占用 - 其它明细挂靠占用
|
||||
-- 明细被删除后占用自动释放,无需额外回滚,也不存在余额字段不一致问题。
|
||||
--
|
||||
-- 适用:已有 yz_backend_reimbursement_items 的环境。
|
||||
-- 幂等:重复执行安全(information_schema 判断列是否存在)。
|
||||
-- =============================================================
|
||||
|
||||
USE `go-platform`;
|
||||
|
||||
-- 1) 挂靠的发票文件 ID(0 = 未挂靠)
|
||||
SET @ddl := (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1 FROM `information_schema`.`COLUMNS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'yz_backend_reimbursement_items'
|
||||
AND `COLUMN_NAME` = 'invoice_file_id'
|
||||
),
|
||||
'SELECT ''invoice_file_id 已存在,跳过'' AS msg',
|
||||
'ALTER TABLE `yz_backend_reimbursement_items` ADD COLUMN `invoice_file_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''挂靠的发票文件ID(yz_system_files.id,0=未挂靠)'' AFTER `receipt_amounts`'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 2) 本条明细本次使用的发票金额
|
||||
SET @ddl := (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1 FROM `information_schema`.`COLUMNS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'yz_backend_reimbursement_items'
|
||||
AND `COLUMN_NAME` = 'invoice_use_amount'
|
||||
),
|
||||
'SELECT ''invoice_use_amount 已存在,跳过'' AS msg',
|
||||
'ALTER TABLE `yz_backend_reimbursement_items` ADD COLUMN `invoice_use_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT ''本条明细挂靠使用的发票金额'' AFTER `invoice_file_id`'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 3) 辅助索引:按挂靠发票快速聚合(重复执行前先判断)
|
||||
SET @ddl := (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1 FROM `information_schema`.`STATISTICS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'yz_backend_reimbursement_items'
|
||||
AND `INDEX_NAME` = 'idx_reimb_invoice'
|
||||
),
|
||||
'SELECT ''idx_reimb_invoice 已存在,跳过'' AS msg',
|
||||
'ALTER TABLE `yz_backend_reimbursement_items` ADD INDEX `idx_reimb_invoice` (`reimbursement_id`, `invoice_file_id`)'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 4) 核对结果
|
||||
SHOW FULL COLUMNS FROM `yz_backend_reimbursement_items`
|
||||
WHERE `Field` IN ('invoice_file_id', 'invoice_use_amount');
|
||||
@@ -84,6 +84,10 @@ type BackendReimbursementItem struct {
|
||||
ReceiptFileIDs string `orm:"column(receipt_file_ids);size(500)" json:"receipt_file_ids"`
|
||||
ReceiptURLs string `orm:"column(receipt_urls);size(500)" json:"receipt_urls"`
|
||||
ReceiptAmounts string `orm:"column(receipt_amounts);size(500)" json:"receipt_amounts"`
|
||||
// 发票挂靠:一张票可分数次报销,这里记录本条明细挂靠的发票文件与本次使用金额。
|
||||
// 发票余额由后端按「票面金额 - 各明细占用」实时聚合,不额外存余额字段。
|
||||
InvoiceFileID uint64 `orm:"column(invoice_file_id);default(0)" json:"invoice_file_id"`
|
||||
InvoiceUseAmount float64 `orm:"column(invoice_use_amount);type(decimal);digits(10);decimals(2);default(0)" json:"invoice_use_amount"`
|
||||
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"`
|
||||
|
||||
@@ -39,6 +39,7 @@ func Register() {
|
||||
// 注意:beego 同一路径重复 Router 会覆盖,故 /auth/logout 只注册一次
|
||||
beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit")
|
||||
beego.Router("/auth/logout", &authctl.AuthLoginController{}, "get:LogoutPage;post:LogoutAction")
|
||||
beego.Router("/auth/clear-site-data", &authctl.AuthLoginController{}, "post:ClearSiteData")
|
||||
beego.Router("/auth/tenants", &authctl.AuthLoginController{}, "get:Tenants")
|
||||
beego.Router("/auth/switch-tenant", &authctl.AuthLoginController{}, "post:SwitchTenant")
|
||||
beego.Router("/auth/sessions", &authctl.AuthLoginController{}, "get:Sessions")
|
||||
|
||||
@@ -299,6 +299,7 @@ func RegisterAuthRoutes() {
|
||||
// 报销与审批管理
|
||||
beego.Router("/backend/reimbursements", &controllers.BackendReimburseController{}, "get:List;post:Create")
|
||||
beego.Router("/backend/reimbursements/dashboard", &controllers.BackendReimburseController{}, "get:Dashboard")
|
||||
beego.Router("/backend/reimbursements/invoices/available", &controllers.BackendReimburseController{}, "get:AvailableInvoices")
|
||||
beego.Router("/backend/reimbursements/types/manage", &controllers.BackendReimburseController{}, "get:TypesManage")
|
||||
beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType")
|
||||
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType")
|
||||
|
||||
@@ -133,6 +133,47 @@ func CreateSession(info SessionInfo) (*models.AuthSession, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ReuseSession 授权码换令牌时复用登录阶段已经建立的会话。
|
||||
//
|
||||
// authorize 阶段已经用 yz_sid Cookie 校验过登录态,换码只是给这个已登录会话
|
||||
// 签发令牌,不应再 CreateSession:默认「1 号 1 机」策略会把刚登录的会话踢成
|
||||
// revoked,而浏览器 Cookie 里仍是旧 sid,之后任何一次回到认证中心都会被判定
|
||||
// 未登录并重新显示登录页(表现为「登录成功后又跳回登录界面」)。
|
||||
//
|
||||
// 找不到有效会话(过期、被下线等)时才回退到新建会话。
|
||||
func ReuseSession(info SessionInfo) (*models.AuthSession, error) {
|
||||
var list []models.AuthSession
|
||||
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("identity_id", info.IdentityID).
|
||||
Filter("tid", info.Tid).
|
||||
Filter("revoked", 0).
|
||||
OrderBy("-login_at").
|
||||
All(&list)
|
||||
if err == nil {
|
||||
now := time.Now()
|
||||
var fallback *models.AuthSession
|
||||
for i := range list {
|
||||
if !list[i].ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
// 优先复用同一应用的登录会话;同一浏览器会话被多个应用共享时,
|
||||
// client_id 可能为空(直接访问登录页),此时复用最近的有效会话。
|
||||
if list[i].ClientID == info.ClientID {
|
||||
_ = TouchSession(list[i].Sid)
|
||||
return &list[i], nil
|
||||
}
|
||||
if fallback == nil {
|
||||
fallback = &list[i]
|
||||
}
|
||||
}
|
||||
if fallback != nil {
|
||||
_ = TouchSession(fallback.Sid)
|
||||
return fallback, nil
|
||||
}
|
||||
}
|
||||
return CreateSession(info)
|
||||
}
|
||||
|
||||
// GetSession 查询有效会话(已吊销或已过期返回错误)
|
||||
func GetSession(sid string) (*models.AuthSession, error) {
|
||||
var s models.AuthSession
|
||||
|
||||
+118
-3
@@ -202,10 +202,26 @@
|
||||
.form-foot {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #a3acbf;
|
||||
}
|
||||
/* 底部「清除缓存」文字按钮 */
|
||||
.cache-clear-btn {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
transition: color .2s;
|
||||
}
|
||||
.cache-clear-btn:hover { color: var(--primary-dark); text-decoration: underline; }
|
||||
.cache-clear-btn:disabled { opacity: .6; cursor: default; text-decoration: none; }
|
||||
|
||||
.error {
|
||||
display: none;
|
||||
@@ -737,7 +753,10 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-foot">© 2026 云泽科技 版权所有</div>
|
||||
<div class="form-foot">
|
||||
<span>© 2026 云泽科技 版权所有</span>
|
||||
<button id="cacheClearBtn" class="cache-clear-btn" type="button" onclick="clearSiteCache()" title="仅清除本站在当前浏览器中的缓存数据">清除缓存</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1075,7 +1094,9 @@
|
||||
return;
|
||||
}
|
||||
if (res.redirect) { window.location.href = res.redirect; return; }
|
||||
window.location.href = '/auth/login';
|
||||
// 会话已建立但确实没有回跳地址:明确提示,绝不再跳回登录页造成死循环
|
||||
showError('登录成功,但未获取到回跳地址,请从业务系统重新进入');
|
||||
setBtnLoading(false);
|
||||
})
|
||||
.catch(function () {
|
||||
showError('网络异常,请稍后重试');
|
||||
@@ -1094,7 +1115,7 @@
|
||||
.then(function (res) {
|
||||
if (res.code !== 200) { showError(res.msg || '切换失败'); return; }
|
||||
if (res.redirect) { window.location.href = res.redirect; return; }
|
||||
window.location.href = '/auth/login';
|
||||
showError('已进入所选企业,但未获取到回跳地址,请从业务系统重新进入');
|
||||
})
|
||||
.catch(function () { showError('网络异常,请稍后重试'); });
|
||||
}
|
||||
@@ -1105,6 +1126,100 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除本站在当前浏览器里的全部缓存数据。
|
||||
*
|
||||
* localStorage / sessionStorage / document.cookie / Cache Storage /
|
||||
* IndexedDB / Service Worker 都受同源策略约束,只会作用于认证中心自己的
|
||||
* origin,不会影响 back.yunzer.cn 等其它站点。
|
||||
* HttpOnly 的 yz_sid 前端读不到,交给 /auth/clear-site-data 的
|
||||
* Clear-Site-Data 响应头与 Set-Cookie 一起清除。
|
||||
*/
|
||||
function clearSiteCache() {
|
||||
var btn = document.getElementById('cacheClearBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '清除中...'; }
|
||||
|
||||
// 1. Web Storage(记住账号、各类前端状态)
|
||||
try {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
} catch (e) { /* 隐私模式下存储可能不可访问 */ }
|
||||
|
||||
// 2. 当前站点可写的 Cookie。只针对本站 host:不尝试 .yunzer.cn 等父域,
|
||||
// 否则会连带清掉 back.yunzer.cn / platform.yunzer.cn 等兄弟站点的数据。
|
||||
// 两次删除分别覆盖 host-only cookie 与显式 Domain=当前 host 的 cookie。
|
||||
try {
|
||||
var cookieNames = (document.cookie || '').split(';').map(function (item) {
|
||||
return item.split('=')[0].trim();
|
||||
}).filter(Boolean);
|
||||
var host = window.location.hostname;
|
||||
var expired = '=; Max-Age=0; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
cookieNames.forEach(function (name) {
|
||||
document.cookie = name + expired;
|
||||
document.cookie = name + expired + '; Domain=' + host;
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
var tasks = [];
|
||||
|
||||
// 3. Cache Storage(离线资源 / 静态资源缓存)
|
||||
if (window.caches && caches.keys) {
|
||||
tasks.push(caches.keys().then(function (keys) {
|
||||
return Promise.all((keys || []).map(function (key) { return caches.delete(key); }));
|
||||
}).catch(function () {}));
|
||||
}
|
||||
|
||||
// 4. Service Worker 注册
|
||||
if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
|
||||
tasks.push(navigator.serviceWorker.getRegistrations().then(function (regs) {
|
||||
return Promise.all((regs || []).map(function (reg) { return reg.unregister(); }));
|
||||
}).catch(function () {}));
|
||||
}
|
||||
|
||||
// 5. IndexedDB(databases() 在部分浏览器不可用,跳过即可)
|
||||
if (window.indexedDB && indexedDB.databases) {
|
||||
tasks.push(indexedDB.databases().then(function (dbs) {
|
||||
return Promise.all((dbs || []).map(function (db) {
|
||||
return new Promise(function (resolve) {
|
||||
if (!db || !db.name) { resolve(); return; }
|
||||
var done = false;
|
||||
var finish = function () { if (!done) { done = true; resolve(); } };
|
||||
var req = indexedDB.deleteDatabase(db.name);
|
||||
req.onsuccess = req.onerror = req.onblocked = finish;
|
||||
// 其它标签页占用数据库时 onblocked 可能不触发,兜底避免卡住
|
||||
setTimeout(finish, 1500);
|
||||
});
|
||||
}));
|
||||
}).catch(function () {}));
|
||||
}
|
||||
|
||||
// 6. 后端清 HttpOnly 会话 Cookie,并让浏览器按 origin 清 HTTP 缓存与存储;
|
||||
// 服务尚未部署新端点(404)或接口异常时退回已有登出接口,
|
||||
// 至少保证会话 Cookie 被清除;整体超时也不阻断前面的前端清理结果
|
||||
var legacyClear = function () {
|
||||
return fetch('/auth/logout', { method: 'POST', credentials: 'same-origin', cache: 'no-store' })
|
||||
.catch(function () {});
|
||||
};
|
||||
var clearRequest = fetch('/auth/clear-site-data', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (res) {
|
||||
if (res.ok) return;
|
||||
return legacyClear();
|
||||
}).catch(legacyClear);
|
||||
tasks.push(Promise.race([
|
||||
clearRequest,
|
||||
new Promise(function (resolve) { setTimeout(resolve, 5000); })
|
||||
]));
|
||||
|
||||
Promise.all(tasks).then(function () {
|
||||
// 保留 client_id / redirect,清完直接从登录页重新开始
|
||||
window.location.replace('/auth/login?client_id=' + encodeURIComponent(CLIENT_ID) +
|
||||
(REDIRECT ? '&redirect=' + encodeURIComponent(REDIRECT) : ''));
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 微信扫码登录(扫码 → 小程序内确认;已接 scan/start、scan/status) ====================
|
||||
var scanScene = '';
|
||||
var scanExpireAt = 0;
|
||||
|
||||
@@ -228,7 +228,13 @@ export function uploadExpenseReceipt(filePath) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 组装后端要求的费用明细请求体(发票只传文件 ID 列表,发票金额暂不在端上维护) */
|
||||
/**
|
||||
* 组装后端要求的费用明细请求体(发票只传文件 ID 列表,发票金额暂不在端上维护)。
|
||||
*
|
||||
* 不传发票挂靠字段:挂靠只允许挂「当前报销单」里上传的发票,而移动端保存是
|
||||
* 「删旧重建」且不带 receipt_amounts(票面金额),重建期间发票池暂时为空或无票面,
|
||||
* 带上旧挂靠会导致校验失败并留下半重建数据。挂靠统一在 Web 端建立。
|
||||
*/
|
||||
function buildExpenseItemPayload(item) {
|
||||
const receipts = item.receipts || []
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user