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,
|
};
|
}
|