yyb
5 小时以前 df5efb2ca2b0cf74d9160ffe2b6c215c4ddc9c99
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js
@@ -1,7 +1,29 @@
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, watch } from "vue";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from "vue";
import {
  buildFinReimbursementListParams,
  buildTravelReimbursementSaveDto,
  canDeleteReimbursementRow,
  canEditReimbursementRow,
  filterRowsByReimbursementType,
  FIN_REIMBURSEMENT_TYPE,
  mapFinReimbursementDetailRow,
  mapTravelReimbursementRow,
  resolveReimbursementDeleteId,
  unwrapFinReimbursementDetail,
  unwrapFinReimbursementPage,
  validateReimbursementPersistDto,
} from "../shared/finReimbursementMappers.js";
import { consumeReimburseEditFromApprove } from "../shared/reimburseApproveBridge.js";
import {
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
@@ -32,92 +54,10 @@
  return String(u.status) === "0";
}
function demoFlowNodes(names = ["部门主管", "财务审核"]) {
  return names.map((name, i) => ({
    approverId: `mock_${i + 1}`,
    approverName: name,
    sortOrder: i + 1,
    nodeOrder: i + 1,
    nodeStatus: i === 0 ? "process" : "wait",
    approveOpinion: "",
    approveTime: "",
  }));
}
export function useTravelReimburse() {
  const { proxy } = getCurrentInstance();
  const allRows = ref([
    {
      id: "1",
      reimburseNo: "TR202605090001",
      applicantId: "mock_1",
      employeeNo: "zhangsan",
      employeeName: "张三",
      applicantNo: "zhangsan",
      applicantName: "张三",
      reimburseReason: "赴上海参加行业展会及客户拜访。",
      travelStartTime: "2026-05-10 08:00:00",
      travelEndTime: "2026-05-13 18:00:00",
      travelDays: 4,
      departurePlace: "杭州",
      destination: "上海",
      hotelStandard: 600,
      hotelDays: 3,
      livingSubsidy: 400,
      applyAmount: 4580,
      payee: "张三",
      expenseDetails: [
        { id: "d1", invoiceDate: "2026-05-10", expenseSubject: "transport", amount: 553, description: "高铁往返" },
        { id: "d2", invoiceDate: "2026-05-11", expenseSubject: "hotel", amount: 1680, description: "酒店住宿" },
      ],
      attachmentList: [{ name: "高铁票.pdf", url: "/mock/invoice1.pdf" }],
      invoiceAttachments: [{ name: "高铁票.pdf", url: "/mock/invoice1.pdf" }],
      approvalFlowNodes: demoFlowNodes(),
      currentNodeIndex: 0,
      approvalResult: "pending",
      rejectReason: "",
      approvalRecords: [],
      needSpecialApproval: false,
      deptId: "101",
      deptName: "销售部",
      travelTier: "tier1",
      createTime: "2026-05-09 10:20:00",
    },
    {
      id: "2",
      reimburseNo: "TR202605080002",
      applicantId: "mock_2",
      employeeNo: "lisi",
      employeeName: "李四",
      applicantNo: "lisi",
      applicantName: "李四",
      reimburseReason: "成都分公司技术支持。",
      travelStartTime: "2026-05-05 09:00:00",
      travelEndTime: "2026-05-07 17:00:00",
      travelDays: 3,
      departurePlace: "武汉",
      destination: "成都",
      hotelStandard: 450,
      hotelDays: 2,
      livingSubsidy: 240,
      applyAmount: 2100,
      payee: "李四",
      expenseDetails: [{ id: "d3", invoiceDate: "2026-05-06", expenseSubject: "meal", amount: 180, description: "工作餐" }],
      attachmentList: [],
      invoiceAttachments: [],
      approvalFlowNodes: demoFlowNodes().map((n, i) => ({ ...n, nodeStatus: "finish", approveOpinion: "同意", approveTime: "2026-05-08 11:00:00" })),
      currentNodeIndex: 1,
      approvalResult: "approved",
      rejectReason: "",
      approvalRecords: [{ operatorName: "部门主管", result: "approved", opinion: "同意", time: "2026-05-08 10:00:00" }],
      needSpecialApproval: false,
      deptId: "102",
      deptName: "技术部",
      travelTier: "tier2",
      createTime: "2026-05-07 16:00:00",
    },
  ]);
  const allRows = ref([]);
  const searchForm = reactive({ applicantKeyword: "", travelStartFrom: "", travelEndTo: "" });
  const tableLoading = ref(false);
@@ -130,43 +70,38 @@
  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 filteredList = computed(() => {
    let list = [...allRows.value];
    const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => {
        const name = (r.applicantName || r.employeeName || "").toLowerCase();
        const no = (r.applicantNo || r.employeeNo || "").toLowerCase();
        return name.includes(kw) || no.includes(kw);
      });
    }
    if (searchForm.travelStartFrom) {
      list = list.filter((r) => !r.travelStartTime || r.travelStartTime.slice(0, 10) >= searchForm.travelStartFrom);
    }
    if (searchForm.travelEndTo) {
      list = list.filter((r) => !r.travelEndTime || r.travelEndTime.slice(0, 10) <= searchForm.travelEndTo);
    }
    return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
  });
  const tableData = computed(() => allRows.value);
  watch(
    filteredList,
    (list) => {
      page.total = list.length;
      const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
      if (page.current > maxPage) page.current = maxPage;
    },
    { immediate: true }
  );
  const tableData = computed(() => {
    const start = (page.current - 1) * page.size;
    return filteredList.value.slice(start, start + page.size);
  });
  async function fetchList() {
    tableLoading.value = true;
    try {
      const res = await listFinReimbursementPage(
        buildFinReimbursementListParams({
          page,
          searchForm,
          reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL,
        })
      );
      const { records, total } = unwrapFinReimbursementPage(res);
      allRows.value = filterRowsByReimbursementType(
        records,
        FIN_REIMBURSEMENT_TYPE.TRAVEL
      ).map(mapTravelReimbursementRow);
      page.total = total;
    } catch {
      allRows.value = [];
      page.total = 0;
      proxy?.$modal?.msgError?.("差旅报销列表加载失败");
    } finally {
      tableLoading.value = false;
    }
  }
  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
@@ -238,11 +173,21 @@
      label: "操作",
      align: "center",
      fixed: "right",
      width: 200,
      width: 220,
      operation: [
        { name: "编辑", type: "text", disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved", clickFun: (row) => openFormDialog("edit", row) },
        {
          name: "编辑",
          type: "text",
          disabled: (row) => !canEditReimbursementRow(row),
          clickFun: (row) => openFormDialog("edit", row),
        },
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        { name: "审批", type: "text", disabled: (row) => row.approvalResult !== "pending", clickFun: (row) => openApprove(row) },
        {
          name: "删除",
          type: "danger",
          disabled: (row) => !canDeleteReimbursementRow(row),
          clickFun: (row) => confirmRemoveRow(row),
        },
      ],
    },
  ]);
@@ -416,8 +361,7 @@
  function handleQuery() {
    page.current = 1;
    tableLoading.value = true;
    setTimeout(() => { tableLoading.value = false; }, 150);
    return fetchList();
  }
  function resetSearch() {
@@ -430,11 +374,70 @@
  function pagination(obj) {
    page.current = obj.page;
    page.size = obj.limit;
    return fetchList();
  }
  function openDetail(row) {
    detailRow.value = { ...row };
  async function loadTravelDetailRow(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.TRAVEL);
  }
  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 loadTravelDetailRow(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) {
@@ -455,14 +458,24 @@
    if (!allUsersCache.value.length) await loadUserPool();
    Object.assign(form, createEmptyForm());
    if (mode === "edit" && row) {
      let editRow = row;
      try {
        editRow = await loadTravelDetailRow(row);
      } catch {
        proxy?.$modal?.msgError?.("加载报销详情失败");
        return;
      }
      Object.assign(form, {
        ...JSON.parse(JSON.stringify(row)),
        attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])),
        approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])),
        expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])),
        ...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(row.applicantId);
      applicantFormOptions.value = u ? [u] : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }];
      const u = userById(editRow.applicantId);
      applicantFormOptions.value = u
        ? [u]
        : [{ userId: editRow.applicantId, nickName: editRow.employeeName, userName: editRow.employeeNo }];
    } else {
      form.approvalFlowNodes = [{ approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1 }];
      remoteSearchApplicantForm("");
@@ -496,63 +509,25 @@
        return;
      }
    }
    const days = computeTravelDays(form.travelStartTime, form.travelEndTime);
    const payload = {
      reimburseNo: form.reimburseNo || `TR${dayjs().format("YYYYMMDDHHmmss")}`,
      applicantId: form.applicantId,
      employeeNo: form.employeeNo,
      employeeName: form.employeeName,
      applicantNo: form.employeeNo,
      applicantName: form.employeeName,
      reimburseReason: form.reimburseReason,
      travelStartTime: form.travelStartTime,
      travelEndTime: form.travelEndTime,
      travelDays: days,
      departurePlace: form.departurePlace,
      destination: form.destination,
      hotelStandard: form.hotelStandard,
      hotelDays: form.hotelDays,
      livingSubsidy: form.livingSubsidy,
      applyAmount: form.applyAmount,
      payee: form.payee,
      expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)),
      attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
      invoiceAttachments: mapAttachmentList(form.attachmentList),
      approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
      currentNodeIndex: 0,
      needSpecialApproval: form.needSpecialApproval,
      deptId: form.deptId,
      deptName: form.deptName,
      travelTier: form.travelTier,
    };
    if (formDialog.mode === "add") {
      allRows.value.unshift({
        id: `local_${Date.now()}`,
        ...payload,
        approvalResult: "pending",
        rejectReason: "",
        approvalRecords: [],
        createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
      });
      proxy?.$modal?.msgSuccess?.("提交成功,已进入审批(本地模拟)");
    } else {
      const idx = allRows.value.findIndex((r) => r.id === form.id);
      if (idx !== -1) {
        const prev = allRows.value[idx];
        allRows.value[idx] = {
          ...prev,
          ...payload,
          id: form.id,
          approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult,
          approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
          currentNodeIndex: 0,
          createTime: prev.createTime,
        };
      }
      proxy?.$modal?.msgSuccess?.("保存成功(本地模拟)");
    if (submitSaving.value) return;
    const isEdit = formDialog.mode === "edit";
    const dto = buildTravelReimbursementSaveDto(form, { computeTravelDays });
    const check = validateReimbursementPersistDto(dto, isEdit);
    if (!check.ok) {
      proxy?.$modal?.msgWarning?.(check.message);
      return;
    }
    formDialog.visible = false;
    handleQuery();
    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) {
@@ -593,7 +568,7 @@
  }
  function handleExport() {
    const data = filteredList.value;
    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");
@@ -631,7 +606,14 @@
    reader.readAsText(file, "utf-8");
  }
  onMounted(() => loadUserPool());
  onMounted(async () => {
    loadUserPool();
    await fetchList();
    const editPayload = consumeReimburseEditFromApprove();
    if (editPayload?.reimbursementId != null) {
      await openFormDialog("edit", { reimbursementId: editPayload.reimbursementId });
    }
  });
  return {
    Search,
@@ -648,6 +630,7 @@
    formDialog,
    formRules,
    detailDialog,
    detailLoading,
    detailRow,
    approveDialog,
    approveOpinion,
@@ -678,7 +661,9 @@
    openFormDialog,
    onFormClosed,
    submitForm,
    submitSaving,
    openDetail,
    confirmRemoveRow,
    openApprove,
    approvalActionLabel,
    submitApprove,