gongchunyi
2026-05-28 19f2e3bdbe04e7ea79c6a0bdc8c7318d4837b189
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,634 @@
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,
  filterReimbursementRowsBySearch,
  hasActiveReimbursementSearch,
  canDeleteReimbursementRow,
  canEditReimbursementRow,
  enrichReimbursementListRowsWithApprovalFlow,
  filterRowsByReimbursementType,
  FIN_REIMBURSEMENT_TYPE,
  mapCostReimbursementRow,
  mapFinReimbursementDetailRow,
  resolveReimbursementDeleteId,
  unwrapFinReimbursementDetail,
  unwrapFinReimbursementPage,
  validateReimbursementApprovalNodes,
  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: "",
  });
  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);
      const filtered = filterRowsByReimbursementType(
        records,
        FIN_REIMBURSEMENT_TYPE.COST
      );
      let mapped = filtered.map(mapCostReimbursementRow);
      mapped = await enrichReimbursementListRowsWithApprovalFlow(
        mapped,
        FIN_REIMBURSEMENT_TYPE.COST
      );
      if (hasActiveReimbursementSearch(searchForm)) {
        mapped = filterReimbursementRowsBySearch(mapped, searchForm);
      }
      allRows.value = mapped;
      const dropped = records.length - filtered.length;
      let nextTotal =
        dropped > 0 ? Math.max(0, Number(total) - dropped) : Number(total);
      if (hasActiveReimbursementSearch(searchForm)) {
        nextTotal = mapped.length;
      }
      page.total = nextTotal;
    } 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",
      form.approvalFlowNodes
    );
    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 = "";
    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();
    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;
    }
    const nodeCheck = validateReimbursementApprovalNodes(dto);
    if (!nodeCheck.ok) {
      proxy?.$modal?.msgWarning?.(nodeCheck.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,
  };
}