import { Search } from "@element-plus/icons-vue";
|
import dayjs from "dayjs";
|
import {
|
deleteFinReimbursement,
|
getFinReimbursementDetail,
|
listFinReimbursementPage,
|
persistFinReimbursement,
|
} from "@/api/officeProcessAutomation/finReimbursement.js";
|
import { ElMessageBox } from "element-plus";
|
import { userListNoPageByTenantId } from "@/api/system/user.js";
|
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from "vue";
|
import {
|
buildCostReimbursementSaveDto,
|
buildFinReimbursementListParams,
|
canDeleteReimbursementRow,
|
canEditReimbursementRow,
|
filterRowsByReimbursementType,
|
FIN_REIMBURSEMENT_TYPE,
|
mapCostReimbursementRow,
|
mapFinReimbursementDetailRow,
|
resolveReimbursementDeleteId,
|
unwrapFinReimbursementDetail,
|
unwrapFinReimbursementPage,
|
validateReimbursementPersistDto,
|
} from "../shared/finReimbursementMappers.js";
|
import { consumeReimburseEditFromApprove } from "../shared/reimburseApproveBridge.js";
|
import {
|
EXPENSE_CATEGORY_OPTIONS,
|
CATEGORY_TEMPLATES,
|
EXPENSE_SUBJECT_OPTIONS,
|
expenseCategoryLabel,
|
expenseSubjectLabel,
|
statusLabel,
|
statusTagType,
|
formatApprovalFlowSummary,
|
buildAutoApprovalFlow,
|
getApprovalRuleHint,
|
createEmptyExpenseDetail,
|
createEmptyForm,
|
applyCategoryTemplate,
|
initApprovalFlowNodes,
|
advanceApprovalFlow,
|
rejectApprovalFlow,
|
normalizeImportedRow,
|
} from "./costReimburseUtils.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(amount = 1200, category = "transport") {
|
return buildAutoApprovalFlow(amount, category);
|
}
|
|
export function useCostReimburse() {
|
const { proxy } = getCurrentInstance();
|
|
const allRows = ref([]);
|
|
const searchForm = reactive({
|
applicantKeyword: "",
|
applyTimeFrom: "",
|
applyTimeTo: "",
|
});
|
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 detailLoading = ref(false);
|
const detailRow = ref({});
|
const approveDialog = reactive({ visible: false, row: null });
|
const approveOpinion = ref("");
|
const submitSaving = ref(false);
|
|
const tableData = computed(() =>
|
allRows.value.map((r) => ({
|
...r,
|
approvalFlowSummary: formatApprovalFlowSummary(r),
|
}))
|
);
|
|
async function fetchList() {
|
tableLoading.value = true;
|
try {
|
const res = await listFinReimbursementPage(
|
buildFinReimbursementListParams({
|
page,
|
searchForm,
|
reimbursementType: FIN_REIMBURSEMENT_TYPE.COST,
|
})
|
);
|
const { records, total } = unwrapFinReimbursementPage(res);
|
allRows.value = filterRowsByReimbursementType(
|
records,
|
FIN_REIMBURSEMENT_TYPE.COST
|
).map(mapCostReimbursementRow);
|
page.total = total;
|
} catch {
|
allRows.value = [];
|
page.total = 0;
|
proxy?.$modal?.msgError?.("费用报销列表加载失败");
|
} finally {
|
tableLoading.value = false;
|
}
|
}
|
|
const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
|
|
const detailTotalAmount = computed(() => {
|
const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
|
return Math.round(sum * 100) / 100;
|
});
|
|
const approvalRuleHint = computed(() =>
|
getApprovalRuleHint(form.applyAmount ?? detailTotalAmount.value, form.expenseCategory)
|
);
|
|
const tableColumn = ref([
|
{ label: "报销单号", prop: "reimburseNo", width: 150 },
|
{ label: "申请人编号", prop: "applicantNo", width: 110 },
|
{ label: "申请人", prop: "applicantName", minWidth: 90 },
|
{ label: "报销金额(元)", prop: "applyAmount", width: 110 },
|
{ label: "报销原因", prop: "reimburseReason", minWidth: 160, showOverflowTooltip: true },
|
{ label: "申请时间", prop: "applyTime", width: 165 },
|
{ label: "创建时间", prop: "createTime", width: 165 },
|
{
|
label: "报销状态",
|
prop: "approvalResult",
|
width: 100,
|
dataType: "tag",
|
formatData: (v) => statusLabel(v),
|
formatType: (v) => statusTagType(v),
|
},
|
{
|
label: "审批流程",
|
prop: "approvalFlowSummary",
|
minWidth: 200,
|
showOverflowTooltip: true,
|
},
|
{
|
dataType: "action",
|
label: "操作",
|
align: "center",
|
fixed: "right",
|
width: 220,
|
operation: [
|
{
|
name: "编辑",
|
type: "text",
|
disabled: (row) => !canEditReimbursementRow(row),
|
clickFun: (row) => openFormDialog("edit", row),
|
},
|
{ name: "详情", type: "text", clickFun: (row) => openDetail(row) },
|
{
|
name: "删除",
|
type: "danger",
|
disabled: (row) => !canDeleteReimbursementRow(row),
|
clickFun: (row) => confirmRemoveRow(row),
|
},
|
],
|
},
|
]);
|
|
const formRules = {
|
applicantId: [{ required: true, message: "请选择员工", trigger: "change" }],
|
expenseCategory: [{ required: true, message: "请选择费用类型", trigger: "change" }],
|
reimburseReason: [{ required: true, message: "请填写报销原因", trigger: "blur" }],
|
applyAmount: [{ required: true, message: "请填写报销金额", trigger: "blur" }],
|
payee: [{ required: true, message: "请填写收款人", trigger: "blur" }],
|
payeeAccount: [{ required: true, message: "请填写收款账号", trigger: "blur" }],
|
bankBranch: [{ 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",
|
},
|
],
|
};
|
|
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 autoAssignApprovalFlow() {
|
const amount = Number(form.applyAmount) || detailTotalAmount.value || 0;
|
form.approvalFlowNodes = buildAutoApprovalFlow(amount, form.expenseCategory || "other");
|
nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
|
}
|
|
function onExpenseCategoryChange(val) {
|
if (val && !(form.expenseDetails || []).length) {
|
applyCategoryTemplate(form, val);
|
syncApplyAmountFromDetails();
|
}
|
autoAssignApprovalFlow();
|
}
|
|
function applyTemplate(category) {
|
applyCategoryTemplate(form, category);
|
syncApplyAmountFromDetails();
|
autoAssignApprovalFlow();
|
proxy?.$modal?.msgSuccess?.(`已应用「${CATEGORY_TEMPLATES[category]?.label || category}」填报模板`);
|
}
|
|
function onDetailAmountChange() {
|
syncApplyAmountFromDetails();
|
autoAssignApprovalFlow();
|
}
|
|
function onApprovalFlowChange() {
|
nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
|
}
|
|
function addExpenseDetail() {
|
form.expenseDetails.push(createEmptyExpenseDetail());
|
}
|
|
function removeExpenseDetail(index) {
|
form.expenseDetails.splice(index, 1);
|
syncApplyAmountFromDetails();
|
autoAssignApprovalFlow();
|
}
|
|
function syncApplyAmountFromDetails() {
|
form.applyAmount = detailTotalAmount.value;
|
}
|
|
function handleQuery() {
|
page.current = 1;
|
return fetchList();
|
}
|
|
function resetSearch() {
|
searchForm.applicantKeyword = "";
|
searchForm.applyTimeFrom = "";
|
searchForm.applyTimeTo = "";
|
handleQuery();
|
}
|
|
function pagination(obj) {
|
page.current = obj.page;
|
page.size = obj.limit;
|
return fetchList();
|
}
|
|
async function loadCostDetailRow(row) {
|
const id = resolveReimbursementDeleteId(row);
|
if (id == null) {
|
throw new Error("missing id");
|
}
|
const res = await getFinReimbursementDetail(id);
|
const raw = unwrapFinReimbursementDetail(res);
|
return mapFinReimbursementDetailRow(raw, FIN_REIMBURSEMENT_TYPE.COST);
|
}
|
|
async function openDetail(row) {
|
const id = resolveReimbursementDeleteId(row);
|
if (id == null) {
|
proxy?.$modal?.msgWarning?.("无法查看详情:缺少报销单 ID");
|
return;
|
}
|
detailDialog.visible = true;
|
detailLoading.value = true;
|
detailRow.value = { ...row };
|
try {
|
detailRow.value = await loadCostDetailRow(row);
|
} catch {
|
proxy?.$modal?.msgError?.("加载详情失败");
|
detailDialog.visible = false;
|
} finally {
|
detailLoading.value = false;
|
}
|
}
|
|
async function confirmRemoveRow(row) {
|
const id = resolveReimbursementDeleteId(row);
|
if (id == null) {
|
proxy?.$modal?.msgWarning?.("无法删除:缺少报销单 ID");
|
return;
|
}
|
const title = row.reimburseNo || row.billNo || row.reimburseReason || "该报销单";
|
try {
|
await ElMessageBox.confirm(
|
`确定要删除「${title}」吗?删除后不可恢复。`,
|
"删除确认",
|
{
|
type: "warning",
|
confirmButtonText: "确定删除",
|
cancelButtonText: "取消",
|
distinguishCancelAndClose: true,
|
autofocus: false,
|
}
|
);
|
} catch {
|
return;
|
}
|
try {
|
await deleteFinReimbursement([id]);
|
proxy?.$modal?.msgSuccess?.("删除成功");
|
if (detailDialog.visible && resolveReimbursementDeleteId(detailRow.value) === id) {
|
detailDialog.visible = false;
|
}
|
await handleQuery();
|
} catch {
|
proxy?.$modal?.msgError?.("删除失败");
|
}
|
}
|
|
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) {
|
let editRow = row;
|
try {
|
editRow = await loadCostDetailRow(row);
|
} catch {
|
proxy?.$modal?.msgError?.("加载报销详情失败");
|
return;
|
}
|
Object.assign(form, {
|
...JSON.parse(JSON.stringify(editRow)),
|
reimbursementId: editRow.reimbursementId ?? editRow.id,
|
attachmentList: JSON.parse(JSON.stringify(editRow.attachmentList || editRow.invoiceAttachments || [])),
|
approvalFlowNodes: JSON.parse(JSON.stringify(editRow.approvalFlowNodes || [])),
|
expenseDetails: JSON.parse(JSON.stringify(editRow.expenseDetails || [])),
|
});
|
const u = userById(editRow.applicantId);
|
applicantFormOptions.value = u
|
? [u]
|
: [{ userId: editRow.applicantId, nickName: editRow.employeeName, userName: editRow.employeeNo }];
|
} else {
|
form.approvalFlowNodes = buildAutoApprovalFlow(0, "other");
|
remoteSearchApplicantForm("");
|
}
|
formDialog.visible = true;
|
nextTick(() => {
|
formRef.value?.clearValidate?.();
|
});
|
}
|
|
function onFormClosed() {
|
formRef.value?.resetFields?.();
|
}
|
|
async function submitForm() {
|
try {
|
await formRef.value?.validate?.();
|
} catch {
|
return;
|
}
|
if (!(form.expenseDetails || []).length) {
|
proxy?.$modal?.msgWarning?.("请至少添加一条报销明细");
|
return;
|
}
|
syncApplyAmountFromDetails();
|
autoAssignApprovalFlow();
|
|
if (submitSaving.value) return;
|
const isEdit = formDialog.mode === "edit";
|
const dto = buildCostReimbursementSaveDto(form);
|
const check = validateReimbursementPersistDto(dto, isEdit);
|
if (!check.ok) {
|
proxy?.$modal?.msgWarning?.(check.message);
|
return;
|
}
|
submitSaving.value = true;
|
try {
|
await persistFinReimbursement(dto, isEdit);
|
proxy?.$modal?.msgSuccess?.(isEdit ? "保存成功" : "提交成功");
|
formDialog.visible = false;
|
await handleQuery();
|
} catch {
|
proxy?.$modal?.msgError?.(isEdit ? "保存失败" : "提交失败");
|
} finally {
|
submitSaving.value = false;
|
}
|
}
|
|
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 = allRows.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(async () => {
|
loadUserPool();
|
await fetchList();
|
const editPayload = consumeReimburseEditFromApprove();
|
if (editPayload?.reimbursementId != null) {
|
await openFormDialog("edit", { reimbursementId: editPayload.reimbursementId });
|
}
|
});
|
|
return {
|
Search,
|
EXPENSE_CATEGORY_OPTIONS,
|
CATEGORY_TEMPLATES,
|
EXPENSE_SUBJECT_OPTIONS,
|
expenseCategoryLabel,
|
expenseSubjectLabel,
|
searchForm,
|
tableLoading,
|
page,
|
tableData,
|
tableColumn,
|
importInputRef,
|
formRef,
|
form,
|
formDialog,
|
formRules,
|
detailDialog,
|
detailLoading,
|
detailRow,
|
approveDialog,
|
approveOpinion,
|
applicantFormSearchLoading,
|
applicantFormOptions,
|
flowUserOptions,
|
detailTotalAmount,
|
approvalRuleHint,
|
handleQuery,
|
resetSearch,
|
pagination,
|
remoteSearchApplicantForm,
|
userSelectLabel,
|
onApplicantChange,
|
onExpenseCategoryChange,
|
applyTemplate,
|
onDetailAmountChange,
|
onApprovalFlowChange,
|
addExpenseDetail,
|
removeExpenseDetail,
|
syncApplyAmountFromDetails,
|
autoAssignApprovalFlow,
|
openFormDialog,
|
onFormClosed,
|
submitForm,
|
submitSaving,
|
openDetail,
|
approvalActionLabel,
|
submitApprove,
|
handleExport,
|
handleImportClick,
|
onImportFile,
|
};
|
}
|