import { Search } from "@element-plus/icons-vue"; import dayjs from "dayjs"; import { userListNoPageByTenantId } from "@/api/system/user.js"; import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; import { EXPENSE_SUBJECT_OPTIONS, expenseSubjectLabel, statusLabel, statusTagType, detectTravelTier, getTravelStandardByTier, computeTravelDays, createEmptyExpenseDetail, createEmptyForm, initApprovalFlowNodes, advanceApprovalFlow, rejectApprovalFlow, mockDeptBudget, normalizeImportedRow, } from "./travelReimburseUtils.js"; function unwrapArray(payload) { if (Array.isArray(payload)) return payload; if (payload?.data && Array.isArray(payload.data)) return payload.data; if (payload?.rows && Array.isArray(payload.rows)) return payload.rows; return []; } function isActiveUser(u) { if (u.delFlag === "2" || u.delFlag === 2) return false; if (u.status == null) return true; return String(u.status) === "0"; } function demoFlowNodes(names = ["部门主管", "财务审核"]) { return names.map((name, i) => ({ approverId: `mock_${i + 1}`, approverName: name, sortOrder: i + 1, nodeOrder: i + 1, nodeStatus: i === 0 ? "process" : "wait", approveOpinion: "", approveTime: "", })); } export function useTravelReimburse() { const { proxy } = getCurrentInstance(); const allRows = ref([ { id: "1", reimburseNo: "TR202605090001", applicantId: "mock_1", employeeNo: "zhangsan", employeeName: "张三", applicantNo: "zhangsan", applicantName: "张三", reimburseReason: "赴上海参加行业展会及客户拜访。", travelStartTime: "2026-05-10 08:00:00", travelEndTime: "2026-05-13 18:00:00", travelDays: 4, departurePlace: "杭州", destination: "上海", hotelStandard: 600, hotelDays: 3, livingSubsidy: 400, applyAmount: 4580, payee: "张三", expenseDetails: [ { id: "d1", invoiceDate: "2026-05-10", expenseSubject: "transport", amount: 553, description: "高铁往返" }, { id: "d2", invoiceDate: "2026-05-11", expenseSubject: "hotel", amount: 1680, description: "酒店住宿" }, ], attachmentList: [{ name: "高铁票.pdf", url: "/mock/invoice1.pdf" }], invoiceAttachments: [{ name: "高铁票.pdf", url: "/mock/invoice1.pdf" }], approvalFlowNodes: demoFlowNodes(), currentNodeIndex: 0, approvalResult: "pending", rejectReason: "", approvalRecords: [], needSpecialApproval: false, deptId: "101", deptName: "销售部", travelTier: "tier1", createTime: "2026-05-09 10:20:00", }, { id: "2", reimburseNo: "TR202605080002", applicantId: "mock_2", employeeNo: "lisi", employeeName: "李四", applicantNo: "lisi", applicantName: "李四", reimburseReason: "成都分公司技术支持。", travelStartTime: "2026-05-05 09:00:00", travelEndTime: "2026-05-07 17:00:00", travelDays: 3, departurePlace: "武汉", destination: "成都", hotelStandard: 450, hotelDays: 2, livingSubsidy: 240, applyAmount: 2100, payee: "李四", expenseDetails: [{ id: "d3", invoiceDate: "2026-05-06", expenseSubject: "meal", amount: 180, description: "工作餐" }], attachmentList: [], invoiceAttachments: [], approvalFlowNodes: demoFlowNodes().map((n, i) => ({ ...n, nodeStatus: "finish", approveOpinion: "同意", approveTime: "2026-05-08 11:00:00" })), currentNodeIndex: 1, approvalResult: "approved", rejectReason: "", approvalRecords: [{ operatorName: "部门主管", result: "approved", opinion: "同意", time: "2026-05-08 10:00:00" }], needSpecialApproval: false, deptId: "102", deptName: "技术部", travelTier: "tier2", createTime: "2026-05-07 16:00:00", }, ]); const searchForm = reactive({ applicantKeyword: "", travelStartFrom: "", travelEndTo: "" }); const tableLoading = ref(false); const page = reactive({ current: 1, size: 10, total: 0 }); const importInputRef = ref(null); const allUsersCache = ref([]); const applicantFormSearchLoading = ref(false); const applicantFormOptions = ref([]); const formRef = ref(); const form = reactive(createEmptyForm()); const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false }); const detailDialog = reactive({ visible: false }); const detailRow = ref({}); const approveDialog = reactive({ visible: false, row: null }); const approveOpinion = ref(""); const filteredList = computed(() => { let list = [...allRows.value]; const kw = (searchForm.applicantKeyword || "").trim().toLowerCase(); if (kw) { list = list.filter((r) => { const name = (r.applicantName || r.employeeName || "").toLowerCase(); const no = (r.applicantNo || r.employeeNo || "").toLowerCase(); return name.includes(kw) || no.includes(kw); }); } if (searchForm.travelStartFrom) { list = list.filter((r) => !r.travelStartTime || r.travelStartTime.slice(0, 10) >= searchForm.travelStartFrom); } if (searchForm.travelEndTo) { list = list.filter((r) => !r.travelEndTime || r.travelEndTime.slice(0, 10) <= searchForm.travelEndTo); } return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1)); }); watch( filteredList, (list) => { page.total = list.length; const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1); if (page.current > maxPage) page.current = maxPage; }, { immediate: true } ); const tableData = computed(() => { const start = (page.current - 1) * page.size; return filteredList.value.slice(start, start + page.size); }); const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser)); const travelDaysDisplay = computed(() => { const d = computeTravelDays(form.travelStartTime, form.travelEndTime); return d == null ? "" : String(d); }); const travelTierLabel = computed(() => { const std = getTravelStandardByTier(form.travelTier || detectTravelTier(form.destination)); return `按${std.label}标准`; }); const suggestedLivingSubsidy = computed(() => { const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || form.hotelDays || 0; const std = getTravelStandardByTier(form.travelTier); return Math.round(std.mealPerDay * days * 100) / 100; }); const suggestedTransportSubsidy = computed(() => { const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || 0; const std = getTravelStandardByTier(form.travelTier); return Math.round(std.transportPerDay * days * 100) / 100; }); const suggestedHotelLimit = computed(() => { const nights = form.hotelDays || 0; const perNight = form.hotelStandard ?? getTravelStandardByTier(form.travelTier).hotelPerNight; return Math.round(perNight * nights * 100) / 100; }); const detailTotalAmount = computed(() => { const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0); return Math.round(sum * 100) / 100; }); const overBudgetWarnings = computed(() => buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value)); const budgetHint = computed(() => { if (!form.deptId) return { visible: false }; const b = mockDeptBudget(form.deptId); const apply = Number(form.applyAmount) || detailTotalAmount.value || 0; const after = b.remainingAmount - apply; return { visible: true, type: after < 0 ? "error" : "info", title: `部门预算联动(${form.deptName || b.deptId})`, description: `年度预算 ${b.totalBudget} 元,已用 ${b.usedAmount} 元,剩余 ${b.remainingAmount} 元;本单申请后预计剩余 ${Math.round(after * 100) / 100} 元。`, }; }); const tableColumn = ref([ { label: "报销单号", prop: "reimburseNo", width: 150 }, { label: "申请人编号", prop: "applicantNo", width: 110 }, { label: "申请人", prop: "applicantName", minWidth: 90 }, { label: "出差开始", prop: "travelStartTime", width: 165 }, { label: "出差结束", prop: "travelEndTime", width: 165 }, { label: "创建时间", prop: "createTime", width: 165 }, { label: "状态", prop: "approvalResult", width: 100, dataType: "tag", formatData: (v) => statusLabel(v), formatType: (v) => statusTagType(v), }, { dataType: "action", label: "操作", align: "center", fixed: "right", width: 200, operation: [ { name: "编辑", type: "text", disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved", clickFun: (row) => openFormDialog("edit", row) }, { name: "详情", type: "text", clickFun: (row) => openDetail(row) }, { name: "审批", type: "text", disabled: (row) => row.approvalResult !== "pending", clickFun: (row) => openApprove(row) }, ], }, ]); const formRules = { applicantId: [{ required: true, message: "请选择员工", trigger: "change" }], reimburseReason: [{ required: true, message: "请填写报销原因", trigger: "blur" }], travelStartTime: [{ required: true, message: "请选择出差开始时间", trigger: "change" }], travelEndTime: [ { required: true, message: "请选择出差结束时间", trigger: "change" }, { validator: (_r, val, cb) => { if (!form.travelStartTime || !val) { cb(); return; } if (computeTravelDays(form.travelStartTime, val) == null) cb(new Error("结束时间须晚于开始时间")); else cb(); }, trigger: "change", }, ], departurePlace: [{ required: true, message: "请填写出差地", trigger: "blur" }], destination: [{ required: true, message: "请填写目的地", trigger: "blur" }], applyAmount: [{ required: true, message: "请填写申请金额", trigger: "blur" }], payee: [{ required: true, message: "请填写收款人", trigger: "blur" }], approvalFlowNodes: [ { validator: (_r, _v, cb) => { const nodes = form.approvalFlowNodes || []; if (!nodes.length) { cb(new Error("请至少配置一个审批节点")); return; } if (nodes.some((n) => n.approverId == null || n.approverId === "")) { cb(new Error("每个节点须选择审批人")); return; } cb(); }, trigger: "change", }, ], }; function buildOverBudgetWarnings(f, detailTotal, hotelLimit, transportLimit, mealLimit) { const warnings = []; const bySubject = { transport: 0, hotel: 0, meal: 0, other: 0 }; (f.expenseDetails || []).forEach((d) => { const key = d.expenseSubject || "other"; bySubject[key] = (bySubject[key] || 0) + (Number(d.amount) || 0); }); if (bySubject.transport > transportLimit && transportLimit > 0) { warnings.push(`交通费 ${bySubject.transport} 元超出标准 ${transportLimit} 元`); } if (bySubject.hotel > hotelLimit && hotelLimit > 0) { warnings.push(`住宿费 ${bySubject.hotel} 元超出限额 ${hotelLimit} 元`); } if (bySubject.meal > mealLimit && mealLimit > 0) { warnings.push(`餐饮费 ${bySubject.meal} 元超出生活补贴建议 ${mealLimit} 元`); } const std = getTravelStandardByTier(f.travelTier); if (f.hotelStandard > std.hotelPerNight) { warnings.push(`酒店标准 ${f.hotelStandard} 元/晚高于${std.label}标准 ${std.hotelPerNight} 元/晚`); } const apply = Number(f.applyAmount) || detailTotal; const standardTotal = transportLimit + hotelLimit + mealLimit; if (apply > standardTotal && standardTotal > 0) { warnings.push(`申请总额 ${apply} 元高于差旅标准合计约 ${standardTotal} 元`); } return warnings; } async function loadUserPool() { try { allUsersCache.value = unwrapArray(await userListNoPageByTenantId()); } catch { allUsersCache.value = []; } } function userSelectLabel(u) { const nick = u.nickName || ""; const name = u.userName || ""; if (nick && name && nick !== name) return `${nick}(${name})`; return nick || name || `用户${u.userId ?? u.id ?? ""}`; } function userById(id) { return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id)); } function employeeNoFromUser(u) { if (!u) return ""; return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : ""); } function filterUsersByQuery(query) { const list = allUsersCache.value.filter(isActiveUser); const q = (query || "").trim().toLowerCase(); if (!q) return [...list]; return list.filter((u) => { const nick = (u.nickName || "").toLowerCase(); const uname = (u.userName || "").toLowerCase(); return nick.includes(q) || uname.includes(q); }); } async function remoteSearchApplicantForm(query) { applicantFormSearchLoading.value = true; try { if (!allUsersCache.value.length) await loadUserPool(); applicantFormOptions.value = filterUsersByQuery(query); } finally { applicantFormSearchLoading.value = false; } } function onApplicantChange(uid) { const u = userById(uid); if (u) { form.employeeName = u.nickName || u.userName || ""; form.employeeNo = employeeNoFromUser(u); form.payee = form.payee || form.employeeName; form.deptId = String(u.deptId ?? u.sysDeptId ?? ""); form.deptName = u.dept?.deptName ?? u.deptName ?? ""; } else { form.employeeName = ""; form.employeeNo = ""; } } function recalcTravelStandards() { form.travelTier = detectTravelTier(form.destination); const std = getTravelStandardByTier(form.travelTier); if (form.hotelStandard == null || form.hotelStandard === 0) form.hotelStandard = std.hotelPerNight; const days = computeTravelDays(form.travelStartTime, form.travelEndTime); if (days != null) { form.travelDays = days; if (form.hotelDays == null) form.hotelDays = Math.max(0, days - 1); if (form.livingSubsidy == null || form.livingSubsidy === 0) form.livingSubsidy = suggestedLivingSubsidy.value; } form.needSpecialApproval = buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value).length > 0; } function onTravelRangeChange() { recalcTravelStandards(); nextTick(() => formRef.value?.validateField?.("travelEndTime")); } function onDetailAmountChange() { recalcTravelStandards(); } function onApprovalFlowChange() { nextTick(() => formRef.value?.validateField?.("approvalFlowNodes")); } function addExpenseDetail() { form.expenseDetails.push(createEmptyExpenseDetail()); } function removeExpenseDetail(index) { form.expenseDetails.splice(index, 1); recalcTravelStandards(); } function mapAttachmentList(list) { return (list || []).map((f, i) => ({ id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`, name: f.name || f.fileName || f.originalFilename || "未命名", url: f.url || f.downloadURL || f.previewURL || f.previewUrl || "", })); } function syncApplyAmountFromDetails() { form.applyAmount = detailTotalAmount.value; recalcTravelStandards(); } function handleQuery() { page.current = 1; tableLoading.value = true; setTimeout(() => { tableLoading.value = false; }, 150); } function resetSearch() { searchForm.applicantKeyword = ""; searchForm.travelStartFrom = ""; searchForm.travelEndTo = ""; handleQuery(); } function pagination(obj) { page.current = obj.page; page.size = obj.limit; } function openDetail(row) { detailRow.value = { ...row }; detailDialog.visible = true; } function openApprove(row) { approveDialog.row = { ...row }; approveDialog.visible = true; } function approvalActionLabel(v) { if (v === "approved") return "通过"; if (v === "rejected") return "驳回"; return "提交"; } async function openFormDialog(mode, row) { formDialog.mode = mode; formDialog.readonly = false; formDialog.title = mode === "add" ? "新增差旅报销" : "编辑差旅报销"; if (!allUsersCache.value.length) await loadUserPool(); Object.assign(form, createEmptyForm()); if (mode === "edit" && row) { Object.assign(form, { ...JSON.parse(JSON.stringify(row)), attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])), approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])), expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])), }); const u = userById(row.applicantId); applicantFormOptions.value = u ? [u] : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }]; } else { form.approvalFlowNodes = [{ approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1 }]; remoteSearchApplicantForm(""); } formDialog.visible = true; nextTick(() => { formRef.value?.clearValidate?.(); recalcTravelStandards(); }); } function onFormClosed() { formRef.value?.resetFields?.(); } async function submitForm() { try { await formRef.value?.validate?.(); } catch { return; } if (!(form.expenseDetails || []).length) { proxy?.$modal?.msgWarning?.("请至少添加一条报销明细"); return; } recalcTravelStandards(); if (form.needSpecialApproval) { try { await proxy.$modal.confirm("存在超支项,提交后将标记为需特批,是否继续?"); } catch { return; } } const days = computeTravelDays(form.travelStartTime, form.travelEndTime); const payload = { reimburseNo: form.reimburseNo || `TR${dayjs().format("YYYYMMDDHHmmss")}`, applicantId: form.applicantId, employeeNo: form.employeeNo, employeeName: form.employeeName, applicantNo: form.employeeNo, applicantName: form.employeeName, reimburseReason: form.reimburseReason, travelStartTime: form.travelStartTime, travelEndTime: form.travelEndTime, travelDays: days, departurePlace: form.departurePlace, destination: form.destination, hotelStandard: form.hotelStandard, hotelDays: form.hotelDays, livingSubsidy: form.livingSubsidy, applyAmount: form.applyAmount, payee: form.payee, expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)), attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])), invoiceAttachments: mapAttachmentList(form.attachmentList), approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes), currentNodeIndex: 0, needSpecialApproval: form.needSpecialApproval, deptId: form.deptId, deptName: form.deptName, travelTier: form.travelTier, }; if (formDialog.mode === "add") { allRows.value.unshift({ id: `local_${Date.now()}`, ...payload, approvalResult: "pending", rejectReason: "", approvalRecords: [], createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"), }); proxy?.$modal?.msgSuccess?.("提交成功,已进入审批(本地模拟)"); } else { const idx = allRows.value.findIndex((r) => r.id === form.id); if (idx !== -1) { const prev = allRows.value[idx]; allRows.value[idx] = { ...prev, ...payload, id: form.id, approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult, approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes), currentNodeIndex: 0, createTime: prev.createTime, }; } proxy?.$modal?.msgSuccess?.("保存成功(本地模拟)"); } formDialog.visible = false; handleQuery(); } async function submitApprove(result) { const row = approveDialog.row; if (!row) return; if (result === "rejected" && !(approveOpinion.value || "").trim()) { proxy?.$modal?.msgWarning?.("驳回须填写审批意见"); return; } const idx = allRows.value.findIndex((r) => r.id === row.id); if (idx === -1) return; const cur = allRows.value[idx]; const operatorName = "当前审批人"; const record = { operatorName, result, opinion: approveOpinion.value || (result === "approved" ? "同意" : "驳回"), time: dayjs().format("YYYY-MM-DD HH:mm:ss"), }; const records = [...(cur.approvalRecords || []), record]; let flowUpdate; if (result === "approved") { flowUpdate = advanceApprovalFlow(cur, approveOpinion.value); } else { flowUpdate = rejectApprovalFlow(cur, approveOpinion.value); } allRows.value[idx] = { ...cur, approvalFlowNodes: flowUpdate.nodes, currentNodeIndex: flowUpdate.currentNodeIndex, approvalResult: flowUpdate.approvalResult, rejectReason: flowUpdate.rejectReason ?? cur.rejectReason, approvalRecords: records, }; proxy?.$modal?.msgSuccess?.(result === "approved" ? "已通过" : "已驳回"); approveDialog.visible = false; handleQuery(); } function handleExport() { const data = filteredList.value; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `差旅报销导出_${dayjs().format("YYYYMMDDHHmmss")}.json`; a.click(); URL.revokeObjectURL(url); proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} 条`); } function handleImportClick() { importInputRef.value?.click?.(); } function onImportFile(e) { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const parsed = JSON.parse(String(reader.result || "")); const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data; if (!Array.isArray(arr) || !arr.length) { proxy?.$modal?.msgWarning?.("导入格式须为差旅报销 JSON 数组"); return; } arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i))); proxy?.$modal?.msgSuccess?.(`成功导入 ${arr.length} 条`); handleQuery(); } catch { proxy?.$modal?.msgError?.("解析失败"); } }; reader.readAsText(file, "utf-8"); } onMounted(() => loadUserPool()); return { Search, EXPENSE_SUBJECT_OPTIONS, expenseSubjectLabel, searchForm, tableLoading, page, tableData, tableColumn, importInputRef, formRef, form, formDialog, formRules, detailDialog, detailRow, approveDialog, approveOpinion, applicantFormSearchLoading, applicantFormOptions, flowUserOptions, travelDaysDisplay, travelTierLabel, suggestedLivingSubsidy, suggestedTransportSubsidy, suggestedHotelLimit, detailTotalAmount, overBudgetWarnings, budgetHint, handleQuery, resetSearch, pagination, remoteSearchApplicantForm, userSelectLabel, onApplicantChange, recalcTravelStandards, onTravelRangeChange, onDetailAmountChange, onApprovalFlowChange, addExpenseDetail, removeExpenseDetail, syncApplyAmountFromDetails, openFormDialog, onFormClosed, submitForm, openDetail, openApprove, approvalActionLabel, submitApprove, handleExport, handleImportClick, onImportFile, }; }