| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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"; |
| | | |
| | | // å»¶è¿åå§å userStoreï¼é¿å
卿¨¡åå è½½æ¶è°ç¨ |
| | | let userStore = null; |
| | | function getUserStore() { |
| | | if (!userStore) { |
| | | userStore = useUserStore(); |
| | | } |
| | | return userStore; |
| | | } |
| | | |
| | | 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 store = getUserStore(); |
| | | const id = store.id; |
| | | if (!id) return; |
| | | let u = userById(id); |
| | | if (!u) { |
| | | u = { |
| | | userId: id, |
| | | nickName: store.nickName, |
| | | userName: store.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, |
| | | }; |
| | | } |