import { computed, reactive, ref } from "vue"; import { userListNoPageByTenantId } from "@/api/system/user"; import useUserStore from "@/store/modules/user"; import { persistFinReimbursement } from "@/api/oa/finReimbursement.js"; import { isActiveUser, unwrapUserList, userAvatarColor, userSelectLabel, userSubLabel, } from "../../_utils/userPickerUtils.js"; import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; import { buildCostReimbursementSaveDto, buildTravelReimbursementSaveDto, fetchFinReimbursementFormDetail, getReimbursementTypeByModuleKey, validateReimbursementApprovalNodes, validateReimbursementPersistDto, } from "../../_utils/finReimbursementMappers.js"; import { applyCategoryTemplate, createEmptyCostForm, EXPENSE_CATEGORY_OPTIONS, EXPENSE_SUBJECT_OPTIONS as COST_SUBJECT_OPTIONS, createEmptyExpenseDetail as createCostDetail, } from "../_utils/costReimburseUtils.js"; import { computeTravelDays, createEmptyExpenseDetail, createEmptyTravelForm, detectTravelTier, EXPENSE_SUBJECT_OPTIONS, getTravelStandardByTier, } from "../_utils/travelReimburseUtils.js"; const userStore = useUserStore(); 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 (Number(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; } export function useFinReimburseForm(moduleKeyRef, modeRef) { const isTravel = computed( () => moduleKeyRef.value === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE ); const form = reactive( moduleKeyRef.value === APPROVAL_MODULE_KEYS.COST_REIMBURSE ? createEmptyCostForm() : createEmptyTravelForm() ); const submitting = ref(false); const loading = ref(false); const allUsersCache = ref([]); const showApplicantPicker = ref(false); const applicantDisplaySub = computed(() => { if (!form.applicantId) return "点击选择申请人"; const u = userById(form.applicantId); if (u) return userSubLabel(u) || form.employeeNo || ""; return form.employeeNo ? `工号 ${form.employeeNo}` : ""; }); const applicantAvatarColor = computed(() => userAvatarColor(form.employeeName || form.employeeNo || "") ); const showCategorySheet = ref(false); const showSubjectSheet = ref(false); const editingDetailIndex = ref(-1); const pickApplicantId = ref(""); const pickCategoryValue = ref(""); const pickSubjectValue = ref(""); const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser)); const travelDaysDisplay = computed(() => { if (!isTravel.value) return ""; const d = computeTravelDays(form.travelStartTime, form.travelEndTime); return d == null ? "" : String(d); }); const travelTierLabel = computed(() => { if (!isTravel.value) return ""; 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(() => { if (!isTravel.value) return []; return buildOverBudgetWarnings( form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value ); }); const expenseSubjectOptions = computed(() => isTravel.value ? EXPENSE_SUBJECT_OPTIONS : COST_SUBJECT_OPTIONS ); const categoryActions = computed(() => EXPENSE_CATEGORY_OPTIONS.map(x => ({ name: x.label, value: x.value })) ); const categoryLabel = computed(() => { const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.value === form.expenseCategory); return hit?.label || "请选择费用类型"; }); async function loadUserPool() { try { allUsersCache.value = unwrapUserList(await userListNoPageByTenantId()); } catch { allUsersCache.value = []; } } function userLabel(u) { return userSelectLabel(u); } 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 fillApplicantFromUser(u) { if (!u) return; form.applicantId = u.userId ?? u.id; 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 ?? ""; } function onApplicantPicked(uidOrUser) { const u = typeof uidOrUser === "object" && uidOrUser ? uidOrUser : userById(uidOrUser); fillApplicantFromUser(u); } /** 新增时默认带出当前登录人,减少选人步骤 */ function tryApplyCurrentUser() { if (modeRef.value === "edit" || form.applicantId) return; const id = userStore.id; if (!id) return; let u = userById(id); if (!u) { u = { userId: id, nickName: userStore.nickName, userName: userStore.name, }; } fillApplicantFromUser(u); } function recalcTravelStandards() { if (!isTravel.value) return; form.travelTier = detectTravelTier(form.destination); const std = getTravelStandardByTier(form.travelTier); if (form.hotelStandard == null || form.hotelStandard === "" || 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 === "") { form.hotelDays = Math.max(0, days - 1); } if (form.livingSubsidy == null || form.livingSubsidy === "" || form.livingSubsidy === 0) { form.livingSubsidy = suggestedLivingSubsidy.value; } } form.needSpecialApproval = overBudgetWarnings.value.length > 0; } function syncApplyAmountFromDetails() { form.applyAmount = detailTotalAmount.value; recalcTravelStandards(); } function addExpenseDetail() { const row = isTravel.value ? createEmptyExpenseDetail() : createCostDetail(); form.expenseDetails.push(row); } function removeExpenseDetail(index) { form.expenseDetails.splice(index, 1); recalcTravelStandards(); } function applyTemplate(category) { applyCategoryTemplate(form, category); syncApplyAmountFromDetails(); } function resetFormForModule() { const empty = isTravel.value ? createEmptyTravelForm() : createEmptyCostForm(); Object.keys(form).forEach(k => delete form[k]); Object.assign(form, empty); if (!form.approvalFlowNodes?.length) { form.approvalFlowNodes = [ { approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }, ]; } } async function loadEdit(reimbursementId) { loading.value = true; try { if (!allUsersCache.value.length) await loadUserPool(); const row = await fetchFinReimbursementFormDetail( { reimbursementId }, moduleKeyRef.value ); if (row?.moduleKey && row.moduleKey !== moduleKeyRef.value) { moduleKeyRef.value = row.moduleKey; } Object.assign(form, JSON.parse(JSON.stringify(row)), { reimbursementId: row.reimbursementId ?? row.id, expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])), approvalFlowNodes: JSON.parse( JSON.stringify( row.approvalFlowNodes?.length ? row.approvalFlowNodes : [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }] ) ), attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])), }); if (!isTravel.value && form.expenseCategory) { /* 已由 mapCost 转为 value */ } recalcTravelStandards(); } finally { loading.value = false; } } async function initForm() { resetFormForModule(); if (!allUsersCache.value.length) await loadUserPool(); if (modeRef.value !== "edit") { form.approvalFlowNodes = [ { approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }, ]; tryApplyCurrentUser(); } } function validateForm() { if (!form.applicantId) { uni.showToast({ title: "请选择员工", icon: "none" }); return false; } if (!(form.reimburseReason || "").trim()) { uni.showToast({ title: "请填写报销原因", icon: "none" }); return false; } if (isTravel.value) { if (!form.travelStartTime) { uni.showToast({ title: "请选择出差开始时间", icon: "none" }); return false; } if (!form.travelEndTime) { uni.showToast({ title: "请选择出差结束时间", icon: "none" }); return false; } if (computeTravelDays(form.travelStartTime, form.travelEndTime) == null) { uni.showToast({ title: "结束时间须晚于开始时间", icon: "none" }); return false; } if (!(form.departurePlace || "").trim()) { uni.showToast({ title: "请填写出差地", icon: "none" }); return false; } if (!(form.destination || "").trim()) { uni.showToast({ title: "请填写目的地", icon: "none" }); return false; } } else if (!form.expenseCategory) { uni.showToast({ title: "请选择费用类型", icon: "none" }); return false; } if (form.applyAmount === "" || form.applyAmount == null) { uni.showToast({ title: "请填写申请金额", icon: "none" }); return false; } if (!(form.payee || "").trim()) { uni.showToast({ title: "请填写收款人", icon: "none" }); return false; } if (!(form.expenseDetails || []).length) { uni.showToast({ title: "请至少添加一条报销明细", icon: "none" }); return false; } const nodes = form.approvalFlowNodes || []; if (!nodes.length || nodes.some(n => n.approverId == null || n.approverId === "")) { uni.showToast({ title: "每个审批节点须选择审批人", icon: "none" }); return false; } return true; } async function submitForm() { if (!validateForm()) return; recalcTravelStandards(); if (isTravel.value && form.needSpecialApproval) { const ok = await new Promise(resolve => { uni.showModal({ title: "超支提醒", content: "存在超支项,提交后将标记为需特批,是否继续?", success: r => resolve(!!r.confirm), }); }); if (!ok) return; } const isEdit = modeRef.value === "edit"; const dto = isTravel.value ? buildTravelReimbursementSaveDto(form, { computeTravelDays }) : buildCostReimbursementSaveDto(form); const check = validateReimbursementPersistDto(dto, isEdit); if (!check.ok) { uni.showToast({ title: check.message, icon: "none" }); return; } const nodeCheck = validateReimbursementApprovalNodes(dto); if (!nodeCheck.ok) { uni.showToast({ title: nodeCheck.message, icon: "none" }); return; } submitting.value = true; try { await persistFinReimbursement(dto, isEdit); uni.showToast({ title: isEdit ? "保存成功" : "提交成功", icon: "success" }); return true; } catch { uni.showToast({ title: isEdit ? "保存失败" : "提交失败", icon: "none" }); return false; } finally { submitting.value = false; } } return { form, isTravel, submitting, loading, flowUserOptions, travelDaysDisplay, travelTierLabel, suggestedLivingSubsidy, suggestedTransportSubsidy, suggestedHotelLimit, detailTotalAmount, overBudgetWarnings, expenseSubjectOptions, categoryActions, categoryLabel, showApplicantPicker, applicantDisplaySub, applicantAvatarColor, showCategorySheet, showSubjectSheet, editingDetailIndex, pickCategoryValue, pickSubjectValue, loadUserPool, userLabel, onApplicantPicked, tryApplyCurrentUser, recalcTravelStandards, syncApplyAmountFromDetails, addExpenseDetail, removeExpenseDetail, applyTemplate, initForm, loadEdit, submitForm, getReimbursementTypeByModuleKey, }; }