yyb
2 天以前 df5efb2ca2b0cf74d9160ffe2b6c215c4ddc9c99
差旅报销费用报销
已添加5个文件
已修改11个文件
1859 ■■■■ 文件已修改
src/api/officeProcessAutomation/finReimbursement.js 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js 623 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js 275 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/officeProcessAutomation/finReimbursement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
import request from "@/utils/request";
/** åˆ†é¡µæŸ¥è¯¢è´¢åŠ¡æŠ¥é”€ GET /finReimbursement/listPage */
export function listFinReimbursementPage(params) {
  return request({
    url: "/finReimbursement/listPage",
    method: "get",
    params,
  });
}
/** è¯¦æƒ… query:Spring ç»‘定 finReimbursementDto.id,勿用 finReimbursementDto[id] */
function buildFinReimbursementDetailParams(idOrDto) {
  const raw =
    typeof idOrDto === "object" && idOrDto !== null
      ? idOrDto.id ?? idOrDto.reimbursementId
      : idOrDto;
  return {
    "finReimbursementDto.id": raw,
    id: raw,
  };
}
/** æŸ¥è¯¢è´¢åŠ¡æŠ¥é”€è¯¦æƒ… GET /finReimbursement/detail */
export function getFinReimbursementDetail(idOrDto) {
  return request({
    url: "/finReimbursement/detail",
    method: "get",
    params: buildFinReimbursementDetailParams(idOrDto),
  });
}
/** æ–°å¢žè´¢åŠ¡æŠ¥é”€ POST /finReimbursement/save */
export function saveFinReimbursement(finReimbursementDto) {
  return request({
    url: "/finReimbursement/save",
    method: "post",
    data: finReimbursementDto,
  });
}
/** ä¿®æ”¹è´¢åŠ¡æŠ¥é”€ POST /finReimbursement/update */
export function updateFinReimbursement(finReimbursementDto) {
  return request({
    url: "/finReimbursement/update",
    method: "post",
    data: finReimbursementDto,
  });
}
/** åˆ é™¤è´¢åŠ¡æŠ¥é”€ DELETE /finReimbursement/delete(body ä¸º ID æ•°ç»„) */
export function deleteFinReimbursement(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter(
    (id) => id != null && id !== ""
  );
  return request({
    url: "/finReimbursement/delete",
    method: "delete",
    data: idList,
  });
}
/** æ–°å¢žèµ° save,修改走 update(与接口文档一致) */
export function persistFinReimbursement(finReimbursementDto, isEdit = false) {
  if (isEdit) {
    return updateFinReimbursement(finReimbursementDto);
  }
  const payload = { ...finReimbursementDto };
  delete payload.id;
  return saveFinReimbursement(payload);
}
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -475,7 +475,7 @@
    applicantId: row.applicantId,
    applicantNo: row.applicantId != null ? String(row.applicantId) : "",
    applicantName: row.applicantName || "",
    approvalType: row.templateName || "",
    approvalType: row.approvalType || row.templateName || "",
    unread: Boolean(row.isApprove) && approvalStatus === "pending",
    isApprove: Boolean(row.isApprove),
    approvalStatus,
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -223,6 +223,64 @@
      </template>
    </el-dialog>
    <!-- å·®æ—…/费用报销详情(审批列表) -->
    <el-dialog
      v-model="reimburseDialog.visible"
      :title="reimburseDialog.mode === 'approve' ? reimburseApproveTitle : reimburseDetailTitle"
      width="1000px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <FinReimburseApprovePanel
        :mode="reimburseDialog.mode"
        :module-key="reimburseDialog.moduleKey"
        :reimburse-row="reimburseDialog.reimburseRow"
        :loading="reimburseDialog.loading"
        v-model:approve-opinion="approveOpinion"
      />
      <template #footer>
        <template v-if="reimburseDialog.mode === 'approve'">
          <el-button
            type="success"
            :loading="approveSubmitting"
            @click="onReimburseApprove('approved')"
          >
            é€š è¿‡
          </el-button>
          <el-button
            type="danger"
            :loading="approveSubmitting"
            @click="onReimburseApprove('rejected')"
          >
            é©³ å›ž
          </el-button>
          <el-button :disabled="approveSubmitting" @click="reimburseDialog.visible = false">
            å– æ¶ˆ
          </el-button>
        </template>
        <template v-else>
          <el-button
            v-if="reimburseDialog.instanceRow?.approvalStatus === 'pending'"
            @click="openEditFromReimburseDetail"
          >
            ä¿® æ”¹
          </el-button>
          <el-button
            v-if="
              reimburseDialog.instanceRow?.approvalStatus === 'pending' &&
              reimburseDialog.instanceRow?.isApprove
            "
            type="primary"
            @click="openReimburseApproveFromDetail"
          >
            åŽ»å®¡æ‰¹
          </el-button>
          <el-button type="primary" @click="reimburseDialog.visible = false">关 é—­</el-button>
        </template>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹æ“ä½œ -->
    <el-dialog
      v-model="approveDialog.visible"
@@ -277,7 +335,9 @@
<script setup>
import { Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { APPROVAL_MODULE_KEYS } from "../approve-shared/approvalModuleRegistry.js";
import FinReimburseApprovePanel from "../../ReimburseManage/shared/components/FinReimburseApprovePanel.vue";
import ApprovalTemplateFormSection from "../approve-shared/components/ApprovalTemplateFormSection.vue";
import ApprovalTemplatePicker from "../approve-shared/components/ApprovalTemplatePicker.vue";
import { useFlowUserOptions } from "../approve-shared/useFlowUserOptions.js";
@@ -309,9 +369,11 @@
  tableColumn,
  detailDialog,
  detailRow,
  reimburseDialog,
  approveDialog,
  approveOpinion,
  approveSubmitting,
  submitReimburseApprove,
  submitDialog,
  isSubmitEdit,
  submitDialogTitle,
@@ -342,8 +404,30 @@
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "审批已提交");
}
const reimburseDetailTitle = computed(() =>
  reimburseDialog.moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
    ? "费用报销详情"
    : "差旅报销详情"
);
const reimburseApproveTitle = computed(() =>
  reimburseDialog.moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
    ? "费用报销审批"
    : "差旅报销审批"
);
async function onApprove(result) {
  const ret = await submitApprove(result);
  if (ret?.needOpinion) {
    ElMessage.warning("驳回时请填写审批意见");
    return;
  }
  if (ret?.ok) {
    ElMessage.success(result === "approved" ? "已通过" : "已驳回");
  }
}
async function onReimburseApprove(result) {
  const ret = await submitReimburseApprove(result);
  if (ret?.needOpinion) {
    ElMessage.warning("驳回时请填写审批意见");
    return;
@@ -357,10 +441,10 @@
  return formatDisplayTime(time) || "—";
}
function openApproveFromDetail() {
async function openApproveFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openApprove(row);
  await openApprove(row);
}
function openEditFromDetail() {
@@ -369,6 +453,19 @@
  openEditDialog(row);
}
function openEditFromReimburseDetail() {
  const row = reimburseDialog.instanceRow;
  reimburseDialog.visible = false;
  if (row) openEditDialog(row);
}
async function openReimburseApproveFromDetail() {
  const row = reimburseDialog.instanceRow;
  if (!row) return;
  reimburseDialog.mode = "approve";
  approveOpinion.value = "";
}
onMounted(() => {
  loadFlowUsers();
  loadSearchBusinessTypeOptions();
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -13,7 +13,13 @@
import useUserStore from "@/store/modules/user";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, reactive, ref } from "vue";
import { computed, getCurrentInstance, reactive, ref } from "vue";
import {
  inferReimburseModuleKeyFromInstance,
  loadReimburseDetailForInstance,
  navigateToReimburseManageForEdit,
  resolveFinReimbursementIdFromInstance,
} from "../../ReimburseManage/shared/reimburseApproveBridge.js";
import {
  fetchBusinessTypeOptions,
  formatDisplayTime,
@@ -43,6 +49,7 @@
} from "./approveListConstants.js";
export function useApproveList() {
  const { proxy } = getCurrentInstance() || {};
  const userStore = useUserStore();
  const tableData = ref([]);
@@ -74,6 +81,16 @@
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const approveSubmitting = ref(false);
  /** å·®æ—…/费用报销专用详情、审批弹窗 */
  const reimburseDialog = reactive({
    visible: false,
    mode: "detail",
    moduleKey: "",
    loading: false,
    reimburseRow: {},
    instanceRow: null,
  });
  const submitDialog = reactive({ visible: false, step: 1, mode: "add" });
  const submitEditRow = ref(null);
@@ -242,15 +259,52 @@
    fetchApprovalList();
  }
  function openDetail(row) {
  async function openReimburseDetail(row, mode) {
    const moduleKey = inferReimburseModuleKeyFromInstance(row);
    if (!moduleKey) return false;
    reimburseDialog.mode = mode;
    reimburseDialog.moduleKey = moduleKey;
    reimburseDialog.instanceRow = row;
    reimburseDialog.visible = true;
    reimburseDialog.loading = true;
    reimburseDialog.reimburseRow = {};
    try {
      const { reimburseRow, moduleKey: resolvedMk } =
        await loadReimburseDetailForInstance(row, moduleKey);
      reimburseDialog.moduleKey = resolvedMk || moduleKey;
      reimburseDialog.reimburseRow = reimburseRow;
      return true;
    } catch {
      ElMessage.error("加载报销详情失败");
      reimburseDialog.visible = false;
      return false;
    } finally {
      reimburseDialog.loading = false;
    }
  }
  async function openDetail(row) {
    if (isReimburseApprovalInstance(row)) {
      await openReimburseDetail(row, "detail");
      return;
    }
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openApprove(row) {
  async function openApprove(row) {
    if (inferReimburseModuleKeyFromInstance(row)) {
      approveOpinion.value = "";
      await openReimburseDetail(row, "approve");
      return;
    }
    approveDialog.row = { ...row };
    approveOpinion.value = "";
    approveDialog.visible = true;
  }
  function isReimburseApprovalInstance(row) {
    return Boolean(inferReimburseModuleKeyFromInstance(row));
  }
  function resetSubmitDialogState() {
@@ -267,9 +321,23 @@
    loadSubmitTemplates();
  }
  function openEditDialog(row) {
  async function openEditDialog(row) {
    if (row?.approvalStatus !== "pending") {
      ElMessage.warning("仅审核中的审批可修改");
      return;
    }
    const moduleKey = inferReimburseModuleKeyFromInstance(row);
    if (moduleKey) {
      const rid = resolveFinReimbursementIdFromInstance(row);
      if (rid == null) {
        ElMessage.warning("无法修改:缺少报销单 ID");
        return;
      }
      try {
        await navigateToReimburseManageForEdit(proxy?.$router, moduleKey, rid);
      } catch {
        ElMessage.warning("未找到差旅/费用报销菜单路由,请从左侧菜单进入后再编辑");
      }
      return;
    }
    if (!row?.id) {
@@ -444,6 +512,29 @@
    }
  }
  async function submitReimburseApprove(result) {
    const row = reimburseDialog.instanceRow;
    if (!row?.id) return { ok: false };
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      return { needOpinion: true };
    }
    if (approveSubmitting.value) return { ok: false };
    approveSubmitting.value = true;
    try {
      await approveApprovalInstance(
        buildApproveInstanceDto(row, result, approveOpinion.value)
      );
      reimburseDialog.visible = false;
      await fetchApprovalList();
      return { ok: true, result };
    } catch {
      ElMessage.error("审批操作失败");
      return { ok: false };
    } finally {
      approveSubmitting.value = false;
    }
  }
  async function submitApprove(result) {
    const row = approveDialog.row;
    if (!row?.id) return { ok: false };
@@ -495,9 +586,12 @@
    tableColumn,
    detailDialog,
    detailRow,
    reimburseDialog,
    approveDialog,
    approveOpinion,
    approveSubmitting,
    submitReimburseApprove,
    isReimburseApprovalInstance,
    submitDialog,
    isSubmitEdit,
    submitDialogTitle,
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue
@@ -50,7 +50,10 @@
});
const attachmentFiles = computed(() => {
  const list = props.row?.attachmentList?.length ? props.row.attachmentList : props.row?.invoiceAttachments;
  const list =
    props.row?.attachmentList ||
    props.row?.storageBlobVOList ||
    props.row?.invoiceAttachments;
  return Array.isArray(list) ? list : [];
});
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js
@@ -107,14 +107,19 @@
}
export function statusLabel(v) {
  if (v === "draft") return "草稿";
  if (v === "approved") return "已通过";
  if (v === "paid") return "已付款";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "approved") return "success";
  if (v === "draft") return "info";
  if (v === "approved" || v === "paid") return "success";
  if (v === "rejected") return "danger";
  if (v === "cancelled") return "info";
  return "warning";
}
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
@@ -1,4 +1,4 @@
<!--OA模块:费用报销-->
<!--OA模块:费用报销(列表 /finReimbursement/listPage,reimbursementType=2)-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
@@ -318,13 +318,21 @@
        </el-card>
      </el-form>
      <template #footer>
        <el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">提 äº¤</el-button>
        <el-button
          v-if="!formDialog.readonly"
          type="primary"
          :loading="submitSaving"
          @click="submitForm"
        >
          æ äº¤
        </el-button>
        <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "关 é—­" : "取 æ¶ˆ" }}</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="费用报销详情" width="900px" append-to-body destroy-on-close>
      <div v-loading="detailLoading">
      <DetailPanel :row="detailRow" />
      <el-divider content-position="left">审批流程</el-divider>
      <ApprovalFlowProgress :nodes="detailRow.approvalFlowNodes" :current-index="detailRow.currentNodeIndex ?? 0" />
@@ -340,6 +348,7 @@
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      </div>
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
@@ -406,6 +415,7 @@
  formDialog,
  formRules,
  detailDialog,
  detailLoading,
  detailRow,
  approveDialog,
  approveOpinion,
@@ -431,6 +441,7 @@
  openFormDialog,
  onFormClosed,
  submitForm,
  submitSaving,
  approvalActionLabel,
  submitApprove,
  handleExport,
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.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 {
  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,
@@ -59,52 +81,43 @@
  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.applyTimeFrom) {
      list = list.filter((r) => {
        const t = (r.applyTime || r.createTime || "").slice(0, 10);
        return !t || t >= searchForm.applyTimeFrom;
      });
    }
    if (searchForm.applyTimeTo) {
      list = list.filter((r) => {
        const t = (r.applyTime || r.createTime || "").slice(0, 10);
        return !t || t <= searchForm.applyTimeTo;
      });
    }
    return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
  });
  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).map((r) => ({
  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));
@@ -149,15 +162,15 @@
        {
          name: "编辑",
          type: "text",
          disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved",
          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),
        },
      ],
    },
@@ -295,10 +308,7 @@
  function handleQuery() {
    page.current = 1;
    tableLoading.value = true;
    setTimeout(() => {
      tableLoading.value = false;
    }, 150);
    return fetchList();
  }
  function resetSearch() {
@@ -311,11 +321,70 @@
  function pagination(obj) {
    page.current = obj.page;
    page.size = obj.limit;
    return fetchList();
  }
  function openDetail(row) {
    detailRow.value = { ...row };
  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) {
@@ -336,16 +405,24 @@
    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(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);
      const u = userById(editRow.applicantId);
      applicantFormOptions.value = u
        ? [u]
        : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }];
        : [{ userId: editRow.applicantId, nickName: editRow.employeeName, userName: editRow.employeeNo }];
    } else {
      form.approvalFlowNodes = buildAutoApprovalFlow(0, "other");
      remoteSearchApplicantForm("");
@@ -373,64 +450,25 @@
    syncApplyAmountFromDetails();
    autoAssignApprovalFlow();
    const payload = {
      reimburseNo: form.reimburseNo || `CR${dayjs().format("YYYYMMDDHHmmss")}`,
      applicantId: form.applicantId,
      employeeNo: form.employeeNo,
      employeeName: form.employeeName,
      applicantNo: form.employeeNo,
      applicantName: form.employeeName,
      expenseCategory: form.expenseCategory,
      reimburseReason: form.reimburseReason,
      applyAmount: form.applyAmount,
      payee: form.payee,
      payeeAccount: form.payeeAccount,
      bankBranch: form.bankBranch,
      expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)),
      attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
      invoiceAttachments: (form.attachmentList || []).map((f, i) => ({
        id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`,
        name: f.name || f.fileName || "未命名",
        url: f.url || f.downloadURL || "",
      })),
      approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
      currentNodeIndex: 0,
      deptId: form.deptId,
      deptName: form.deptName,
    };
    if (formDialog.mode === "add") {
      const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
      allRows.value.unshift({
        id: `local_${Date.now()}`,
        ...payload,
        approvalResult: "pending",
        rejectReason: "",
        approvalRecords: [],
        applyTime: now,
        createTime: now,
      });
      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,
          rejectReason: prev.approvalResult === "rejected" ? "" : prev.rejectReason,
          applyTime: prev.applyTime,
          createTime: prev.createTime,
        };
      }
      proxy?.$modal?.msgSuccess?.("保存成功");
    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;
    }
    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) {
@@ -471,7 +509,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");
@@ -509,7 +547,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,
@@ -529,6 +574,7 @@
    formDialog,
    formRules,
    detailDialog,
    detailLoading,
    detailRow,
    approveDialog,
    approveOpinion,
@@ -554,6 +600,7 @@
    openFormDialog,
    onFormClosed,
    submitForm,
    submitSaving,
    openDetail,
    approvalActionLabel,
    submitApprove,
src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
<!-- å·®æ—…/费用报销:审批列表内详情/审批弹窗内容(与报销页弹窗一致) -->
<template>
  <div v-loading="loading">
    <TravelDetailPanel v-if="isTravel" :row="reimburseRow" />
    <CostDetailPanel v-else :row="reimburseRow" />
    <el-divider content-position="left">流程进度</el-divider>
    <ApprovalFlowProgress
      :nodes="reimburseRow.approvalFlowNodes"
      :current-index="reimburseRow.currentNodeIndex ?? 0"
    />
    <template v-if="mode === 'detail'">
      <el-divider content-position="left">审批记录(全流程留痕)</el-divider>
      <el-timeline v-if="reimburseRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in reimburseRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ actionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
    </template>
    <el-form v-else label-width="100px" class="mt16">
      <el-form-item label="审批意见">
        <el-input
          :model-value="approveOpinion"
          type="textarea"
          :rows="3"
          maxlength="500"
          show-word-limit
          :placeholder="isTravel ? '通过可留空;驳回请填写原因' : '通过可留空;驳回请填写具体原因(如:发票模糊需重传)'"
          @update:model-value="$emit('update:approveOpinion', $event)"
        />
      </el-form-item>
    </el-form>
  </div>
</template>
<script setup>
import { computed } from "vue";
import { isTravelReimbursementType } from "../finReimbursementMappers.js";
import ApprovalFlowProgress from "../../travel-reimburse/components/ApprovalFlowProgress.vue";
import CostDetailPanel from "../../cost-reimburse/components/DetailPanel.vue";
import TravelDetailPanel from "../../travel-reimburse/components/DetailPanel.vue";
const props = defineProps({
  mode: { type: String, default: "detail" },
  moduleKey: { type: String, default: "" },
  reimburseRow: { type: Object, default: () => ({}) },
  loading: { type: Boolean, default: false },
  approveOpinion: { type: String, default: "" },
});
defineEmits(["update:approveOpinion"]);
const isTravel = computed(() =>
  isTravelReimbursementType(props.reimburseRow?.reimbursementType ?? props.moduleKey)
);
function actionLabel(v) {
  if (v === "approved") return "通过";
  if (v === "rejected") return "驳回";
  return "提交";
}
</script>
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,152 @@
import { formatDisplayTime } from "../../ApproveManage/approve-template/approveTemplateConstants.js";
import {
  mapRecordResultFromApi,
  mapRecordsFromApi,
  mapTasksToFlowNodes,
} from "../../ApproveManage/approve-list/approveListConstants.js";
function taskStatusToNodeStatus(taskStatus) {
  const s = String(taskStatus ?? "").toUpperCase();
  if (["APPROVED", "COMPLETED", "FINISHED", "PASSED", "AGREE"].includes(s)) {
    return "finish";
  }
  if (["REJECTED", "REJECT", "REFUSE", "REFUSED"].includes(s)) {
    return "error";
  }
  if (["PENDING", "IN_APPROVAL", "PROCESS", "PROCESSING"].includes(s)) {
    return "process";
  }
  return "wait";
}
/** storageBlobVOList â†’ é¡µé¢é™„件列表 */
export function mapReimbursementAttachments(source = {}) {
  const list =
    source.storageBlobVOList ||
    source.storageBlobDTOs ||
    source.storageBlobDTOS ||
    source.storageBlobVOS ||
    source.attachmentList ||
    source.invoiceAttachments ||
    [];
  if (!Array.isArray(list)) return [];
  return list.map((b, i) => ({
    ...b,
    id: b.id ?? b.blobId ?? `att_${i}`,
    name:
      b.fileName ||
      b.originalFilename ||
      b.originalFileName ||
      b.blobName ||
      b.name ||
      "附件",
    url:
      b.url ||
      b.fileUrl ||
      b.downloadUrl ||
      b.downloadURL ||
      b.previewUrl ||
      b.previewURL ||
      b.link ||
      "",
  }));
}
/** å®¡æ‰¹è®°å½•来自 tasks(每条任务一条留痕) */
export function mapTasksToApprovalRecords(tasks) {
  const list = Array.isArray(tasks) ? tasks : [];
  return list
    .map((t, index) => ({
      id: t.id ?? index,
      operatorName: t.approverName || t.operatorName || t.createUserName || "—",
      result: mapRecordResultFromApi(
        t.approveAction ?? t.taskStatus ?? t.status
      ),
      opinion: t.approveComment || t.comment || t.opinion || "",
      time: formatDisplayTime(
        t.approveTime || t.finishTime || t.updateTime || t.createTime || ""
      ),
      levelNo: t.levelNo ?? t.taskLevel,
      raw: t,
    }))
    .sort((a, b) => {
      const la = Number(a.levelNo ?? 0);
      const lb = Number(b.levelNo ?? 0);
      if (la !== lb) return la - lb;
      return String(a.time).localeCompare(String(b.time));
    });
}
/** tasks â†’ ApprovalFlowProgress èŠ‚ç‚¹ */
export function mapTasksToApprovalFlowNodes(tasks) {
  const grouped = mapTasksToFlowNodes(tasks);
  return grouped.map((node, i) => {
    const approvers = node.approvers || [];
    const statuses = approvers.map(a =>
      taskStatusToNodeStatus(a.taskStatus ?? a.status)
    );
    let nodeStatus = "wait";
    if (statuses.includes("error")) nodeStatus = "error";
    else if (statuses.length && statuses.every(s => s === "finish")) {
      nodeStatus = "finish";
    } else if (statuses.includes("process")) nodeStatus = "process";
    const names = approvers.map(a => a.approverName).filter(Boolean).join("、");
    const opinions = approvers
      .map(a => a.approveComment)
      .filter(Boolean)
      .join(";");
    return {
      nodeOrder: node.nodeOrder ?? node.levelNo ?? i + 1,
      sortOrder: node.nodeOrder ?? node.levelNo ?? i + 1,
      approverName: names || "—",
      approveOpinion: opinions,
      approveTime: approvers.find(a => a.approveTime)?.approveTime || "",
      nodeStatus,
      signMode: node.signMode,
    };
  });
}
export function computeApprovalFlowCurrentIndex(approvalFlowNodes = []) {
  const list = approvalFlowNodes || [];
  const processing = list.findIndex(n => n.nodeStatus === "process");
  if (processing >= 0) return processing;
  const errorIdx = list.findIndex(n => n.nodeStatus === "error");
  if (errorIdx >= 0) return errorIdx;
  return list.filter(n => n.nodeStatus === "finish").length;
}
/** è¯¦æƒ… DTO è¡¥å…… tasks / é™„ä»¶ / å®¡æ‰¹è®°å½• */
export function applyFinReimbursementDetailEnrichment(mapped, raw = {}) {
  if (!mapped || typeof mapped !== "object") return mapped;
  const source = { ...raw, ...mapped };
  const tasks = Array.isArray(source.tasks) ? source.tasks : [];
  const attachments = mapReimbursementAttachments(source);
  const approvalRecords = tasks.length
    ? mapTasksToApprovalRecords(tasks)
    : mapRecordsFromApi(source.records || source.approvalRecords);
  const approvalFlowNodes = tasks.length
    ? mapTasksToApprovalFlowNodes(tasks)
    : mapped.approvalFlowNodes || [];
  const currentNodeIndex = computeApprovalFlowCurrentIndex(approvalFlowNodes);
  const rejectReason =
    approvalRecords.find(r => r.result === "rejected")?.opinion ||
    source.rejectReason ||
    "";
  return {
    ...mapped,
    tasks,
    storageBlobVOList: attachments,
    attachmentList: attachments,
    invoiceAttachments: attachments,
    approvalRecords,
    records: tasks.length ? tasks : source.records,
    approvalFlowNodes,
    currentNodeIndex,
    rejectReason,
    flowNodes: tasks.length ? mapTasksToFlowNodes(tasks) : mapped.flowNodes,
  };
}
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,623 @@
import dayjs from "dayjs";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { mapSignModeToApi } from "../../ApproveManage/approve-template/approveTemplateConstants.js";
import { EXPENSE_CATEGORY_OPTIONS } from "../cost-reimburse/costReimburseUtils.js";
import { EXPENSE_SUBJECT_OPTIONS } from "../travel-reimburse/travelReimburseUtils.js";
import { applyFinReimbursementDetailEnrichment } from "./finReimbursementDetailExtras.js";
/** æŠ¥é”€ç±»åž‹ï¼š1-差旅报销,2-费用报销 */
export const FIN_REIMBURSEMENT_TYPE = {
  TRAVEL: "1",
  COST: "2",
};
const REIMBURSEMENT_TYPE_LABEL = {
  [FIN_REIMBURSEMENT_TYPE.TRAVEL]: "差旅报销",
  [FIN_REIMBURSEMENT_TYPE.COST]: "费用报销",
};
/** å½’一化报销类型:1-差旅,2-费用 */
export function normalizeReimbursementType(val) {
  const s = String(val ?? "").trim();
  if (s === "1" || s === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    return FIN_REIMBURSEMENT_TYPE.TRAVEL;
  }
  if (s === "2" || s === FIN_REIMBURSEMENT_TYPE.COST) {
    return FIN_REIMBURSEMENT_TYPE.COST;
  }
  return "";
}
export function reimbursementTypeLabel(type) {
  return REIMBURSEMENT_TYPE_LABEL[normalizeReimbursementType(type)] || "—";
}
export function getModuleKeyByReimbursementType(type) {
  const t = normalizeReimbursementType(type);
  if (t === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    return APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE;
  }
  if (t === FIN_REIMBURSEMENT_TYPE.COST) {
    return APPROVAL_MODULE_KEYS.COST_REIMBURSE;
  }
  return "";
}
/** ä¼˜å…ˆæŽ¥å£ reimbursementType,其次页面 moduleKey / å…¥å‚ */
export function resolveReimbursementType(raw, fallback) {
  const fromApi = normalizeReimbursementType(raw?.reimbursementType);
  if (fromApi) return fromApi;
  return (
    normalizeReimbursementType(fallback) ||
    getReimbursementTypeByModuleKey(fallback) ||
    ""
  );
}
export function isTravelReimbursementType(type) {
  return (
    resolveReimbursementType({ reimbursementType: type }, type) ===
    FIN_REIMBURSEMENT_TYPE.TRAVEL
  );
}
export function filterRowsByReimbursementType(rows, expectedType) {
  const expected = normalizeReimbursementType(expectedType);
  if (!expected) return rows || [];
  return (rows || []).filter((row) => {
    const t = resolveReimbursementType(row, expected);
    return t === expected;
  });
}
export function getReimbursementTypeByModuleKey(moduleKey) {
  if (moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE) {
    return FIN_REIMBURSEMENT_TYPE.TRAVEL;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE) {
    return FIN_REIMBURSEMENT_TYPE.COST;
  }
  return "";
}
export function unwrapFinReimbursementPage(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") {
    return { records: [], total: 0 };
  }
  if (Array.isArray(data.records)) {
    return { records: data.records, total: Number(data.total ?? 0) };
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && Array.isArray(nested.records)) {
    return { records: nested.records, total: Number(nested.total ?? 0) };
  }
  return { records: [], total: 0 };
}
/** è¯¦æƒ…接口 data è§£åŒ… */
export function unwrapFinReimbursementDetail(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") return {};
  if (data.billNo != null || data.id != null || data.reimbursementType != null) {
    return data;
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && !Array.isArray(nested)) {
    return nested;
  }
  if (data.finReimbursementDto && typeof data.finReimbursementDto === "object") {
    return data.finReimbursementDto;
  }
  return data;
}
/** è¯¦æƒ…查询参数(query finReimbursementDto) */
export function buildFinReimbursementDetailParams(id) {
  const raw = id?.id != null ? id.id : id;
  const n = toNumber(raw);
  return { finReimbursementDto: { id: n != null ? n : raw } };
}
/** è¯¦æƒ… DTO â†’ é¡µé¢è¡Œï¼ˆæŒ‰ reimbursementType æ˜ å°„,含 tasks / storageBlobVOList) */
export function mapFinReimbursementDetailRow(raw, reimbursementTypeOrModuleKey) {
  const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey);
  let mapped = {};
  if (type === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    mapped = mapTravelReimbursementRow(raw);
  } else if (type === FIN_REIMBURSEMENT_TYPE.COST) {
    mapped = mapCostReimbursementRow(raw);
  } else {
    mapped = raw || {};
  }
  return {
    ...applyFinReimbursementDetailEnrichment(mapped, raw),
    reimbursementType: type,
    reimbursementTypeLabel: reimbursementTypeLabel(type),
    moduleKey: getModuleKeyByReimbursementType(type),
  };
}
/** å•据状态 â†’ é¡µé¢ approvalResult(兼容 statusLabel) */
export function mapBillStatusToApprovalResult(billStatus) {
  const upper = String(billStatus ?? "").trim().toUpperCase();
  if (upper === "DRAFT") return "draft";
  if (upper === "IN_APPROVAL") return "pending";
  if (upper === "APPROVED") return "approved";
  if (upper === "REJECTED") return "rejected";
  if (upper === "WITHDRAWN") return "cancelled";
  if (upper === "PAID") return "paid";
  return "pending";
}
function pickApplicantQuery(searchForm = {}) {
  const kw = (searchForm.applicantKeyword || "").trim();
  if (!kw) return {};
  if (/[\u4e00-\u9fa5]/.test(kw)) return { applicantName: kw };
  return { applicantCode: kw };
}
/** ç»„装 listPage æŸ¥è¯¢å‚数(page + finReimbursementDto) */
export function buildFinReimbursementListParams({
  page,
  searchForm,
  reimbursementType,
  extraDto = {},
}) {
  const dto = {
    reimbursementType,
    ...pickApplicantQuery(searchForm),
    ...(extraDto && typeof extraDto === "object" ? extraDto : {}),
  };
  if (searchForm?.billStatus) {
    dto.billStatus = searchForm.billStatus;
  }
  const range =
    searchForm?.createTimeRange ??
    searchForm?.applyDateRange ??
    (searchForm?.applyTimeFrom || searchForm?.applyTimeTo
      ? [searchForm.applyTimeFrom, searchForm.applyTimeTo]
      : null);
  if (Array.isArray(range) && range[0]) {
    dto.createTimeStart = range[0];
  }
  if (Array.isArray(range) && range[1]) {
    dto.createTimeEnd = range[1];
  }
  if (reimbursementType === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    if (searchForm?.travelStartFrom) {
      dto.startTimeStart = searchForm.travelStartFrom;
    }
    if (searchForm?.travelEndTo) {
      dto.endTimeEnd = searchForm.travelEndTo;
    }
  }
  return {
    page: {
      current: page.current,
      size: page.size,
    },
    finReimbursementDto: dto,
  };
}
function pickTravelField(obj, keys) {
  if (!obj || typeof obj !== "object") return "";
  for (const key of keys) {
    const v = obj[key];
    if (v != null && v !== "") return v;
  }
  return "";
}
/** å…¼å®¹ list/detail å¤šç§å·®æ—…子对象结构 */
export function pickTravelFromRow(row) {
  if (!row || typeof row !== "object") return {};
  const nested =
    (row.travel && typeof row.travel === "object" ? row.travel : null) ||
    row.finReimbursementTravel ||
    row.finReimbursementTravelDto ||
    row.travelDto ||
    row.travelVO ||
    {};
  const src =
    nested && typeof nested === "object" && Object.keys(nested).length
      ? nested
      : row;
  return {
    startTime: pickTravelField(src, [
      "startTime",
      "travelStartTime",
      "startDate",
      "travelStartDate",
      "departureTime",
    ]),
    endTime: pickTravelField(src, [
      "endTime",
      "travelEndTime",
      "endDate",
      "travelEndDate",
      "returnTime",
    ]),
    travelDays: src.travelDays,
    departureCity: pickTravelField(src, [
      "departureCity",
      "departurePlace",
      "departure",
    ]),
    destinationCity: pickTravelField(src, [
      "destinationCity",
      "destination",
      "destinationPlace",
    ]),
    hotelStandard: src.hotelStandard,
    lodgingDays: src.lodgingDays ?? src.hotelDays,
    mealAllowance: src.mealAllowance ?? src.livingSubsidy,
    transportAllowance: src.transportAllowance ?? src.transportSubsidy,
    lodgingLimit: src.lodgingLimit,
    withinStandard: src.withinStandard,
    standardTag: src.standardTag || "",
    id: src.id,
    reimbursementId: src.reimbursementId,
  };
}
/** åˆ—表/详情时间展示(ISO â†’ YYYY-MM-DD HH:mm:ss) */
export function formatReimbursementDateTime(val) {
  if (val == null || val === "") return "";
  const d = dayjs(val);
  if (!d.isValid()) return String(val);
  const raw = String(val);
  const hasTime = raw.includes("T") || /:\d{2}/.test(raw);
  return hasTime ? d.format("YYYY-MM-DD HH:mm:ss") : d.format("YYYY-MM-DD");
}
/** æŽ¥å£è¡Œ â†’ å·®æ—…报销列表行(兼容 useTravelReimburse å­—段) */
export function mapTravelReimbursementRow(row) {
  if (!row) return {};
  const travel = pickTravelFromRow(row);
  const details = Array.isArray(row.details) ? row.details : [];
  const base = {
    ...row,
    id: row.id,
    reimbursementId: row.id,
    approvalInstanceId: row.approvalInstanceId,
    reimburseNo: row.billNo || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantCode || "",
    applicantName: row.applicantName || "",
    employeeNo: row.applicantCode || "",
    employeeName: row.applicantName || "",
    applicantDeptName: row.applicantDeptName || "",
    reimburseReason: row.reason || "",
    travelStartTime: formatReimbursementDateTime(travel.startTime),
    travelEndTime: formatReimbursementDateTime(travel.endTime),
    travelDays: travel.travelDays,
    departurePlace: travel.departureCity || "",
    destination: travel.destinationCity || "",
    hotelStandard: travel.hotelStandard,
    hotelDays: travel.lodgingDays,
    livingSubsidy: travel.mealAllowance,
    transportSubsidy: travel.transportAllowance,
    lodgingLimit: travel.lodgingLimit,
    needSpecialApproval: travel.withinStandard === "0" || travel.withinStandard === 0,
    standardTag: travel.standardTag || "",
    applyAmount: row.applyAmount,
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    payeeBank: row.payeeBank || "",
    billStatus: row.billStatus,
    approvalResult: mapBillStatusToApprovalResult(row.billStatus),
    createTime: formatReimbursementDateTime(row.createTime),
    expenseDetails: details.map((d) => ({
      ...d,
      expenseSubject: d.expenseCategory,
    })),
    travel:
      row.travel && typeof row.travel === "object" && Object.keys(row.travel).length
        ? row.travel
        : travel,
    details,
    nodes: row.nodes || [],
    approvalFlowNodes: mapNodesToFormFlow(row.nodes),
    tasks: row.tasks || [],
  };
  return base;
}
/** æŽ¥å£è¡Œ â†’ è´¹ç”¨æŠ¥é”€åˆ—表行(兼容 useCostReimburse å­—段) */
export function mapCostReimbursementRow(row) {
  if (!row) return {};
  const details = Array.isArray(row.details) ? row.details : [];
  return {
    ...row,
    id: row.id,
    reimbursementId: row.id,
    approvalInstanceId: row.approvalInstanceId,
    reimburseNo: row.billNo || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantCode || "",
    applicantName: row.applicantName || "",
    employeeNo: row.applicantCode || "",
    employeeName: row.applicantName || "",
    applicantDeptName: row.applicantDeptName || "",
    reimburseReason: row.reason || "",
    expenseCategory: row.expenseType || "",
    applyAmount: row.applyAmount,
    applyTime: row.createTime || "",
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    bankBranch: row.payeeBank || "",
    billStatus: row.billStatus,
    approvalResult: mapBillStatusToApprovalResult(row.billStatus),
    createTime: formatReimbursementDateTime(row.createTime),
    expenseDetails: details.map((d) => ({
      ...d,
      expenseSubject: d.expenseCategory,
    })),
    details,
    nodes: row.nodes || [],
    approvalFlowNodes: mapNodesToFormFlow(row.nodes),
    tasks: row.tasks || [],
  };
}
function toNumber(val) {
  if (val == null || val === "") return undefined;
  const n = Number(val);
  return Number.isNaN(n) ? undefined : n;
}
function expenseSubjectToCategory(subject) {
  const hit = EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === subject);
  return hit?.label || subject || "";
}
function expenseCategoryToType(category) {
  const hit = EXPENSE_CATEGORY_OPTIONS.find((x) => x.value === category);
  return hit?.label || category || "";
}
/** æŽ¥å£ nodes â†’ é¡µé¢å®¡æ‰¹æµï¼ˆå•审批人节点) */
export function mapNodesToFormFlow(nodes = []) {
  return (Array.isArray(nodes) ? nodes : []).map((n, i) => {
    const first = Array.isArray(n.approvers) ? n.approvers[0] : null;
    return {
      ...n,
      nodeOrder: n.levelNo ?? n.nodeOrder ?? i + 1,
      signMode: String(n.approveType || "").toUpperCase() === "OR" ? "or_sign" : "countersign",
      approverId: first?.approverId ?? n.approverId ?? null,
      approverName: first?.approverName ?? n.approverName ?? "",
    };
  });
}
/** é¡µé¢å®¡æ‰¹èŠ‚ç‚¹ â†’ æŽ¥å£ nodes */
export function mapApprovalFlowNodesToApi(nodes = [], templateId) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list
    .map((n, i) => {
      let approvers = [];
      if (Array.isArray(n.approvers) && n.approvers.length) {
        approvers = n.approvers
          .filter((a) => a?.approverId != null && a.approverId !== "")
          .map((a, idx) => ({
            id: a.id,
            nodeId: a.nodeId,
            templateId: a.templateId ?? templateId,
            approverId: toNumber(a.approverId) ?? a.approverId,
            approverName: a.approverName || "",
            sortNo: a.sortNo ?? idx + 1,
          }));
      } else if (n.approverId != null && n.approverId !== "") {
        approvers = [
          {
            approverId: toNumber(n.approverId) ?? n.approverId,
            approverName: n.approverName || "",
            sortNo: 1,
          },
        ];
      }
      if (!approvers.length) return null;
      const node = {
        levelNo: n.levelNo ?? n.nodeOrder ?? i + 1,
        approveType: n.approveType || mapSignModeToApi(n.signMode),
        approvers,
      };
      if (n.id != null) node.id = n.id;
      if (n.templateId != null) node.templateId = n.templateId;
      else if (templateId != null) node.templateId = templateId;
      return node;
    })
    .filter(Boolean);
}
function mapDetailsToApi(details = []) {
  return (details || []).map((d, i) => {
    const item = {
      rowNo: d.rowNo ?? i + 1,
      invoiceDate: d.invoiceDate || undefined,
      expenseCategory: expenseSubjectToCategory(d.expenseSubject ?? d.expenseCategory),
      amount: toNumber(d.amount),
      description: d.description || "",
      invoiceNo: d.invoiceNo || undefined,
      invoiceType: d.invoiceType || undefined,
      invoiceAmount: toNumber(d.invoiceAmount),
      taxRate: toNumber(d.taxRate),
      taxAmount: toNumber(d.taxAmount),
      remark: d.remark || undefined,
    };
    if (d.id != null && !String(d.id).startsWith("ed_")) {
      item.id = toNumber(d.id) ?? d.id;
    }
    if (d.reimbursementId != null) item.reimbursementId = toNumber(d.reimbursementId);
    return item;
  });
}
function sumDetailAmount(details = []) {
  const sum = (details || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
  return Math.round(sum * 100) / 100;
}
/** ä¿®æ”¹æ—¶è¡¥é½ä¸»è¡¨ä¸Žå­è¡¨å…³è” ID */
function applyReimbursementRelations(dto) {
  const rid = dto?.id;
  if (rid == null) return dto;
  if (dto.travel && typeof dto.travel === "object") {
    dto.travel.reimbursementId = rid;
  }
  if (Array.isArray(dto.details)) {
    dto.details.forEach((d) => {
      d.reimbursementId = rid;
    });
  }
  return dto;
}
function resolveReimbursementId(form) {
  const rawId = form?.reimbursementId ?? form?.id;
  if (rawId == null || rawId === "" || String(rawId).startsWith("local_")) {
    return undefined;
  }
  return toNumber(rawId) ?? rawId;
}
/** å·®æ—…表单 â†’ FinReimbursementDto */
export function buildTravelReimbursementSaveDto(form, { computeTravelDays } = {}) {
  const details = mapDetailsToApi(form.expenseDetails);
  const detailTotal = sumDetailAmount(form.expenseDetails);
  const applyAmount = toNumber(form.applyAmount) ?? detailTotal;
  const travelDays =
    form.travelDays != null
      ? toNumber(form.travelDays)
      : computeTravelDays?.(form.travelStartTime, form.travelEndTime);
  const dto = {
    reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL,
    expenseType: "差旅费",
    applicantId: toNumber(form.applicantId),
    applicantCode: form.employeeNo || form.applicantNo || "",
    applicantName: form.employeeName || form.applicantName || "",
    applicantDeptId: toNumber(form.applicantDeptId),
    applicantDeptName: form.applicantDeptName || form.deptName || "",
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || undefined,
    payeeBank: form.payeeBank || undefined,
    billStatus: "IN_APPROVAL",
    deptId: toNumber(form.deptId),
    travel: {
      startTime: form.travelStartTime || undefined,
      endTime: form.travelEndTime || undefined,
      travelDays,
      departureCity: form.departurePlace || "",
      destinationCity: form.destination || "",
      hotelStandard: toNumber(form.hotelStandard),
      lodgingDays: toNumber(form.hotelDays),
      mealAllowance: toNumber(form.livingSubsidy),
      transportAllowance: toNumber(form.transportSubsidy),
      lodgingLimit: toNumber(form.lodgingLimit),
      standardTag: form.standardTag || (form.needSpecialApproval ? "超标特批" : "在标准范围内"),
      withinStandard: form.needSpecialApproval ? "0" : "1",
    },
    details,
    nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId),
  };
  const id = resolveReimbursementId(form);
  if (id != null) dto.id = id;
  if (form.billNo || form.reimburseNo) {
    dto.billNo = form.billNo || form.reimburseNo;
  }
  if (form.approvalInstanceId != null) {
    dto.approvalInstanceId = toNumber(form.approvalInstanceId);
  }
  if (form.approveProcessId != null) {
    dto.approveProcessId = toNumber(form.approveProcessId);
  }
  if (form.travel?.id != null) dto.travel.id = toNumber(form.travel.id);
  return applyReimbursementRelations(dto);
}
/** è´¹ç”¨è¡¨å• â†’ FinReimbursementDto */
export function buildCostReimbursementSaveDto(form) {
  const details = mapDetailsToApi(form.expenseDetails);
  const detailTotal = sumDetailAmount(form.expenseDetails);
  const applyAmount = toNumber(form.applyAmount) ?? detailTotal;
  const dto = {
    reimbursementType: FIN_REIMBURSEMENT_TYPE.COST,
    expenseType: expenseCategoryToType(form.expenseCategory),
    applicantId: toNumber(form.applicantId),
    applicantCode: form.employeeNo || form.applicantNo || "",
    applicantName: form.employeeName || form.applicantName || "",
    applicantDeptId: toNumber(form.applicantDeptId),
    applicantDeptName: form.applicantDeptName || form.deptName || "",
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || "",
    payeeBank: form.bankBranch || form.payeeBank || "",
    billStatus: "IN_APPROVAL",
    deptId: toNumber(form.deptId),
    details,
    nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId),
  };
  const id = resolveReimbursementId(form);
  if (id != null) dto.id = id;
  if (form.billNo || form.reimburseNo) {
    dto.billNo = form.billNo || form.reimburseNo;
  }
  if (form.approvalInstanceId != null) {
    dto.approvalInstanceId = toNumber(form.approvalInstanceId);
  }
  if (form.approveProcessId != null) {
    dto.approveProcessId = toNumber(form.approveProcessId);
  }
  return applyReimbursementRelations(dto);
}
/** åˆ—表行主键(删除/修改用 fin_reimbursement.id) */
export function resolveReimbursementDeleteId(row) {
  const raw = row?.reimbursementId ?? row?.id;
  if (raw == null || raw === "" || String(raw).startsWith("local_")) {
    return undefined;
  }
  const n = toNumber(raw);
  return n != null ? n : raw;
}
/** æ˜¯å¦å…è®¸åˆ é™¤ï¼ˆå®¡æ‰¹ä¸­ã€å·²é€šè¿‡ã€å·²ä»˜æ¬¾ä¸å¯åˆ ï¼‰ */
export function canDeleteReimbursementRow(row) {
  const key = mapBillStatusToApprovalResult(
    row?.billStatus ?? row?.approvalResult ?? row?.status
  );
  return key !== "pending" && key !== "approved" && key !== "paid";
}
/** æ˜¯å¦å…è®¸ç¼–辑(与删除规则一致) */
export function canEditReimbursementRow(row) {
  return canDeleteReimbursementRow(row);
}
/** ä¿®æ”¹åœºæ™¯å¿…须带主键 ID */
export function validateReimbursementPersistDto(dto, isEdit) {
  if (!isEdit) return { ok: true };
  if (dto?.id != null && dto.id !== "") return { ok: true };
  return { ok: false, message: "无法修改:缺少报销单 ID" };
}
src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
import { getFinReimbursementDetail } from "@/api/officeProcessAutomation/finReimbursement.js";
import { matchBusinessTypeValue } from "../../ApproveManage/approve-list/approveListConstants.js";
import {
  APPROVAL_MODULE_KEYS,
  getApprovalModuleConfig,
} from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  getModuleKeyByReimbursementType,
  mapFinReimbursementDetailRow,
  resolveReimbursementType,
  unwrapFinReimbursementDetail,
} from "./finReimbursementMappers.js";
export const REIMBURSE_EDIT_FROM_APPROVE_KEY = "oa_reimburse_edit_from_approve";
const REIMBURSE_MODULE_KEYS = [
  APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE,
  APPROVAL_MODULE_KEYS.COST_REIMBURSE,
];
/** å®¡æ‰¹å®žä¾‹æ˜¯å¦å·®æ—…/费用报销 */
export function inferReimburseModuleKeyFromInstance(row) {
  if (!row) return "";
  for (const moduleKey of REIMBURSE_MODULE_KEYS) {
    const cfg = getApprovalModuleConfig(moduleKey);
    if (!cfg) continue;
    if (
      cfg.businessType != null &&
      cfg.businessType !== "" &&
      matchBusinessTypeValue(row.businessType, cfg.businessType)
    ) {
      return moduleKey;
    }
    if (matchBusinessTypeValue(row.businessType, cfg.approvalType)) {
      return moduleKey;
    }
    const text = `${row.templateName || ""}${row.title || ""}${row.businessName || ""}`;
    if ((cfg.typeLabels || []).some((l) => l && text.includes(l))) {
      return moduleKey;
    }
  }
  return "";
}
export function isReimburseApprovalInstance(row) {
  return Boolean(inferReimburseModuleKeyFromInstance(row));
}
/** å®¡æ‰¹å®žä¾‹å…³è”çš„ fin_reimbursement.id */
export function resolveFinReimbursementIdFromInstance(row) {
  const raw = row?.businessId ?? row?.formPayload?.reimbursementId;
  if (raw == null || raw === "") return undefined;
  const n = Number(raw);
  return Number.isNaN(n) ? raw : n;
}
/** æ‹‰å–报销详情并映射为差旅/费用页面行(以接口 reimbursementType ä¸ºå‡†ï¼‰ */
export async function loadReimburseDetailForInstance(instanceRow, moduleKey) {
  const mk = moduleKey || inferReimburseModuleKeyFromInstance(instanceRow);
  const id = resolveFinReimbursementIdFromInstance(instanceRow);
  if (id == null) {
    throw new Error("missing reimbursement id");
  }
  const res = await getFinReimbursementDetail(id);
  const raw = unwrapFinReimbursementDetail(res);
  const reimburseRow = mapFinReimbursementDetailRow(raw, mk);
  const reimbursementType = resolveReimbursementType(raw, mk);
  const resolvedMk =
    getModuleKeyByReimbursementType(reimbursementType) || mk;
  return {
    reimburseRow,
    instanceRow,
    moduleKey: resolvedMk,
    reimbursementType,
  };
}
export function stashReimburseEditFromApprove(moduleKey, reimbursementId) {
  sessionStorage.setItem(
    REIMBURSE_EDIT_FROM_APPROVE_KEY,
    JSON.stringify({ moduleKey, reimbursementId })
  );
}
export function consumeReimburseEditFromApprove() {
  const raw = sessionStorage.getItem(REIMBURSE_EDIT_FROM_APPROVE_KEY);
  if (!raw) return null;
  sessionStorage.removeItem(REIMBURSE_EDIT_FROM_APPROVE_KEY);
  try {
    return JSON.parse(raw);
  } catch {
    return null;
  }
}
/** ä»Žå·²æ³¨å†Œè·¯ç”±è§£æžå·®æ—…/费用报销菜单 path(避免写死 path å¯¼è‡´ 404) */
export function resolveReimburseManageRoutePath(router, moduleKey) {
  if (!router?.getRoutes) return "";
  const needle =
    moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE
      ? "travel-reimburse"
      : moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
        ? "cost-reimburse"
        : "";
  if (!needle) return "";
  const labelHint =
    moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE ? "差旅" : "费用";
  const hit = router.getRoutes().find((r) => {
    const path = r.path || "";
    if (path.includes(needle)) return true;
    const title = r.meta?.title || "";
    return title.includes(labelHint) && title.includes("报销");
  });
  return hit?.path || "";
}
export async function navigateToReimburseManageForEdit(router, moduleKey, reimbursementId) {
  stashReimburseEditFromApprove(moduleKey, reimbursementId);
  const path = resolveReimburseManageRoutePath(router, moduleKey);
  if (!path) {
    throw new Error("route not found");
  }
  await router.push(path);
}
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
@@ -58,9 +58,10 @@
});
const attachmentFiles = computed(() => {
  const list = props.row?.attachmentList?.length
    ? props.row.attachmentList
    : props.row?.invoiceAttachments;
  const list =
    props.row?.attachmentList ||
    props.row?.storageBlobVOList ||
    props.row?.invoiceAttachments;
  return Array.isArray(list) ? list : [];
});
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -1,4 +1,4 @@
<!--OA模块:差旅报销-->
<!--OA模块:差旅报销(列表 /finReimbursement/listPage,reimbursementType=1)-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
@@ -369,13 +369,21 @@
        </el-card>
      </el-form>
      <template #footer>
        <el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">提 äº¤</el-button>
        <el-button
          v-if="!formDialog.readonly"
          type="primary"
          :loading="submitSaving"
          @click="submitForm"
        >
          æ äº¤
        </el-button>
        <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "关 é—­" : "取 æ¶ˆ" }}</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="差旅报销详情" width="900px" append-to-body destroy-on-close>
      <div v-loading="detailLoading">
      <DetailPanel :row="detailRow" />
      <ApprovalFlowProgress
        class="mt16"
@@ -394,6 +402,7 @@
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      </div>
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
@@ -458,6 +467,7 @@
  formDialog,
  formRules,
  detailDialog,
  detailLoading,
  detailRow,
  approveDialog,
  approveOpinion,
@@ -488,6 +498,7 @@
  openFormDialog,
  onFormClosed,
  submitForm,
  submitSaving,
  openDetail,
  approvalActionLabel,
  submitApprove,
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
@@ -15,14 +15,19 @@
}
export function statusLabel(v) {
  if (v === "draft") return "草稿";
  if (v === "approved") return "通过";
  if (v === "paid") return "已付款";
  if (v === "rejected") return "驳回";
  if (v === "cancelled") return "已撤回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "approved") return "success";
  if (v === "draft") return "info";
  if (v === "approved" || v === "paid") return "success";
  if (v === "rejected") return "danger";
  if (v === "cancelled") return "info";
  return "warning";
}
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,
@@ -48,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));
@@ -156,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),
        },
      ],
    },
  ]);
@@ -334,8 +361,7 @@
  function handleQuery() {
    page.current = 1;
    tableLoading.value = true;
    setTimeout(() => { tableLoading.value = false; }, 150);
    return fetchList();
  }
  function resetSearch() {
@@ -348,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) {
@@ -373,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("");
@@ -414,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) {
@@ -511,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");
@@ -549,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,
@@ -566,6 +630,7 @@
    formDialog,
    formRules,
    detailDialog,
    detailLoading,
    detailRow,
    approveDialog,
    approveOpinion,
@@ -596,7 +661,9 @@
    openFormDialog,
    onFormClosed,
    submitForm,
    submitSaving,
    openDetail,
    confirmRemoveRow,
    openApprove,
    approvalActionLabel,
    submitApprove,