yyb
101 分钟以前 cdb0e306d0b83902908f20da0903bd9df901c81b
feat(travel-reimburse): 完善差旅报销模块界面和功能

- 添加搜索功能,包括申请人、出差开始和结束日期
- 实现导入和导出功能
- 新增差旅报销表单,包含基本信息和差旅标准
- 增强用户交互体验,添加警告提示和表单验证
已添加2个文件
已修改1个文件
867 ■■■■■ 文件已修改
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue 625 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
<!-- å·®æ—…报销:审批流程进度展示 -->
<template>
  <el-steps :active="activeStep" finish-status="success" align-center>
    <el-step
      v-for="(node, index) in sortedNodes"
      :key="index"
      :title="`节点 ${index + 1}`"
      :description="stepDescription(node)"
      :status="stepStatus(node, index)"
    />
  </el-steps>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
  nodes: { type: Array, default: () => [] },
  currentIndex: { type: Number, default: 0 },
});
const sortedNodes = computed(() => {
  const list = props.nodes || [];
  return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0));
});
const activeStep = computed(() => {
  const list = sortedNodes.value;
  if (!list.length) return 0;
  const finished = list.filter((n) => n.nodeStatus === "finish").length;
  const hasError = list.some((n) => n.nodeStatus === "error");
  if (hasError) return Math.max(0, props.currentIndex);
  return finished;
});
function stepDescription(node) {
  const name = (node.approverName || "").trim() || "未指定";
  const opinion = (node.approveOpinion || "").trim();
  if (opinion) return `${name}:${opinion}`;
  return name;
}
function stepStatus(node, index) {
  if (node.nodeStatus === "error") return "error";
  if (node.nodeStatus === "finish") return "success";
  if (node.nodeStatus === "process" || index === props.currentIndex) return "process";
  return "wait";
}
</script>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -1,12 +1,623 @@
<!--
  æ¨¡å—中文名:差旅报销
  ç›®å½•标识:ReimburseManage/travel-reimburse(travel-reimburse â†’ ä¸­æ–‡ï¼šå·®æ—…报销)
  å¤ç”¨é¡µé¢ï¼š@/views/procurementManagement/procurementLedger/index.vue(采购台账;文件名 index.vue â†’ å…¥å£é¡µï¼‰
-->
<!--OA模块:差旅报销-->
<template>
  <ProcurementLedger />
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">出差开始:</span>
        <el-date-picker
          v-model="searchForm.travelStartFrom"
          type="date"
          placeholder="开始日期"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 150px"
          clearable
        />
        <span class="search_title" style="margin-left: 8px">结束:</span>
        <el-date-picker
          v-model="searchForm.travelEndTo"
          type="date"
          placeholder="结束日期"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 150px"
          clearable
        />
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openFormDialog('add')">新增差旅报销</el-button>
      </div>
    </div>
    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="1120px"
      append-to-body
      destroy-on-close
      class="travel-reimburse-form-dialog"
      @closed="onFormClosed"
    >
      <el-alert
        v-if="budgetHint.visible"
        :title="budgetHint.title"
        :type="budgetHint.type"
        :description="budgetHint.description"
        show-icon
        :closable="false"
        class="mb16"
      />
      <el-alert v-if="overBudgetWarnings.length" type="warning" show-icon :closable="false" class="mb16">
        <template #title>差旅标准超支提醒(需特批)</template>
        <ul class="warn-list">
          <li v-for="(w, i) in overBudgetWarnings" :key="i">{{ w }}</li>
        </ul>
      </el-alert>
      <el-form
        ref="formRef"
        :model="form"
        :rules="formRules"
        label-width="120px"
        class="travel-reimburse-form"
        :disabled="formDialog.readonly"
      >
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">基本信息</span></template>
          <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="员工编号">
              <el-input v-model="form.employeeNo" readonly placeholder="选择员工后自动带出" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="员工姓名" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索员工"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="24">
            <el-form-item label="报销原因" prop="reimburseReason">
              <el-input
                v-model="form.reimburseReason"
                type="textarea"
                :rows="3"
                placeholder="请填写出差及报销原因"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="出差开始" prop="travelStartTime">
              <el-date-picker
                v-model="form.travelStartTime"
                type="datetime"
                placeholder="开始时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onTravelRangeChange"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="出差结束" prop="travelEndTime">
              <el-date-picker
                v-model="form.travelEndTime"
                type="datetime"
                placeholder="结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onTravelRangeChange"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="8">
            <el-form-item label="出差天数">
              <el-input :model-value="travelDaysDisplay" readonly>
                <template #append>天</template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="出差地" prop="departurePlace">
              <el-input v-model="form.departurePlace" placeholder="出发城市" @blur="recalcTravelStandards" />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="目的地" prop="destination">
              <el-input v-model="form.destination" placeholder="目的城市" @blur="recalcTravelStandards" />
            </el-form-item>
          </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">差旅标准</span>
              <el-text type="info" size="small">{{ travelTierLabel }} Â· ç”Ÿæ´»è¡¥è´´å»ºè®® {{ suggestedLivingSubsidy }} å…ƒ</el-text>
            </div>
          </template>
          <el-row :gutter="20">
            <el-col :span="8">
              <el-form-item label="酒店标准">
              <el-input-number
                v-model="form.hotelStandard"
                :min="0"
                :precision="2"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="住宿天数">
              <el-input-number
                v-model="form.hotelDays"
                :min="0"
                :max="365"
                :precision="0"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="生活补贴">
              <el-input-number
                v-model="form.livingSubsidy"
                :min="0"
                :precision="2"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
        </el-row>
          <el-row :gutter="20">
            <el-col :span="8">
              <el-form-item label="交通补贴">
                <el-input :model-value="String(suggestedTransportSubsidy)" readonly><template #append>元</template></el-input>
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="住宿限额">
                <el-input :model-value="String(suggestedHotelLimit)" readonly><template #append>元</template></el-input>
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="特批标记">
                <el-tag :type="form.needSpecialApproval ? 'danger' : 'success'" effect="plain">
                  {{ form.needSpecialApproval ? "超支需特批" : "在标准范围内" }}
                </el-tag>
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">金额与收款</span></template>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="申请金额" prop="applyAmount">
                <div class="amount-row">
                  <el-input-number v-model="form.applyAmount" :min="0" :precision="2" controls-position="right" class="amount-input" />
                  <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
                    æŒ‰æ˜Žç»†æ±‡æ€» {{ detailTotalAmount }} å…ƒ
                  </el-button>
                </div>
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="收款人" prop="payee">
                <el-input v-model="form.payee" placeholder="请输入收款人" maxlength="50" />
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">报销明细</span>
              <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">新增明细</el-button>
            </div>
          </template>
        <el-table :data="form.expenseDetails" border size="small" class="detail-table">
          <el-table-column type="index" label="序号" width="55" align="center" />
          <el-table-column label="发票日期" width="150">
            <template #default="{ row }">
              <el-date-picker
                v-if="!formDialog.readonly"
                v-model="row.invoiceDate"
                type="date"
                value-format="YYYY-MM-DD"
                size="small"
                style="width: 100%"
              />
              <span v-else>{{ row.invoiceDate || "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column label="费用科目" width="130">
            <template #default="{ row }">
              <el-select
                v-if="!formDialog.readonly"
                v-model="row.expenseSubject"
                size="small"
                style="width: 100%"
                @change="recalcTravelStandards"
              >
                <el-option
                  v-for="opt in EXPENSE_SUBJECT_OPTIONS"
                  :key="opt.value"
                  :label="opt.label"
                  :value="opt.value"
                />
              </el-select>
              <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
            </template>
          </el-table-column>
          <el-table-column label="金额" width="120">
            <template #default="{ row }">
              <el-input-number
                v-if="!formDialog.readonly"
                v-model="row.amount"
                :min="0"
                :precision="2"
                size="small"
                controls-position="right"
                style="width: 100%"
                @change="onDetailAmountChange"
              />
              <span v-else>{{ row.amount ?? "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column label="描述" min-width="140">
            <template #default="{ row }">
              <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说明" />
              <span v-else>{{ row.description || "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column v-if="!formDialog.readonly" label="操作" width="70" align="center">
            <template #default="{ $index }">
              <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">附件(发票)</span></template>
          <el-form-item label-width="0" class="attachment-form-item">
            <div class="upload-block">
              <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="点击选择文件" />
            </div>
          </el-form-item>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">审批流程</span></template>
          <el-form-item prop="approvalFlowNodes" label-width="0">
          <ApprovalFlowEditor
            v-if="!formDialog.readonly"
            v-model="form.approvalFlowNodes"
            :user-options="flowUserOptions"
            @update:model-value="onApprovalFlowChange"
          />
          <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
          <p v-if="!formDialog.readonly" class="flow-tip">至少保留一个节点;审核中、已通过的单据不可编辑。</p>
        </el-form-item>
        </el-card>
      </el-form>
      <template #footer>
        <el-button v-if="!formDialog.readonly" type="primary" @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>
      <DetailPanel :row="detailRow" />
      <ApprovalFlowProgress
        class="mt16"
        :nodes="detailRow.approvalFlowNodes"
        :current-index="detailRow.currentNodeIndex ?? 0"
      />
      <el-divider content-position="left">审批记录(全流程留痕)</el-divider>
      <el-timeline v-if="detailRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in detailRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹ -->
    <el-dialog
      v-model="approveDialog.visible"
      title="差旅报销审批"
      width="1000px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <DetailPanel :row="approveDialog.row" />
      <el-divider content-position="left">流程进度</el-divider>
      <ApprovalFlowProgress
        :nodes="approveDialog.row?.approvalFlowNodes"
        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
      />
      <el-form label-width="100px" class="mt16">
        <el-form-item label="审批意见">
          <el-input
            v-model="approveOpinion"
            type="textarea"
            :rows="3"
            maxlength="500"
            show-word-limit
            placeholder="通过可留空;驳回请填写原因"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="success" @click="submitApprove('approved')">通 è¿‡</el-button>
        <el-button type="danger" @click="submitApprove('rejected')">驳 å›ž</el-button>
        <el-button @click="approveDialog.visible = false">取 æ¶ˆ</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
import ApprovalFlowProgress from "./components/ApprovalFlowProgress.vue";
import DetailPanel from "./components/DetailPanel.vue";
import { useTravelReimburse } from "./useTravelReimburse.js";
const tr = useTravelReimburse();
const {
  Search,
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  importInputRef,
  formRef,
  form,
  formDialog,
  formRules,
  detailDialog,
  detailRow,
  approveDialog,
  approveOpinion,
  applicantFormSearchLoading,
  applicantFormOptions,
  flowUserOptions,
  travelDaysDisplay,
  travelTierLabel,
  suggestedLivingSubsidy,
  suggestedTransportSubsidy,
  suggestedHotelLimit,
  detailTotalAmount,
  overBudgetWarnings,
  budgetHint,
  handleQuery,
  resetSearch,
  pagination,
  remoteSearchApplicantForm,
  userSelectLabel,
  onApplicantChange,
  recalcTravelStandards,
  onTravelRangeChange,
  onDetailAmountChange,
  onApprovalFlowChange,
  addExpenseDetail,
  removeExpenseDetail,
  syncApplyAmountFromDetails,
  openFormDialog,
  onFormClosed,
  submitForm,
  openDetail,
  approvalActionLabel,
  submitApprove,
  handleExport,
  handleImportClick,
  onImportFile,
} = tr;
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.mb16 {
  margin-bottom: 16px;
}
.mb8 {
  margin-bottom: 8px;
}
.mt16 {
  margin-top: 16px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.sr-only-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.form-section {
  margin-bottom: 16px;
  border: 1px solid var(--el-border-color-lighter);
}
.form-section :deep(.el-card__header) {
  padding: 12px 16px;
  background: var(--el-fill-color-lighter);
}
.form-section :deep(.el-card__body) {
  padding: 16px 16px 4px;
}
.card-header-title {
  font-size: 15px;
  font-weight: 600;
}
.card-header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.amount-row {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
}
.amount-input {
  flex: 1;
  min-width: 160px;
}
.w-full {
  width: 100%;
}
.attachment-form-item {
  margin-bottom: 0;
}
.detail-table {
  margin-bottom: 0;
}
.section-title {
  font-size: 15px;
  font-weight: 600;
  margin: 8px 0 12px;
  color: var(--el-text-color-primary);
  border-left: 3px solid var(--el-color-primary);
  padding-left: 8px;
}
.field-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 4px;
}
.warn-list {
  margin: 0;
  padding-left: 18px;
}
.detail-toolbar {
  margin-bottom: 8px;
}
.upload-block {
  width: 100%;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 8px;
}
.sync-btn {
  margin-top: 4px;
}
.travel-reimburse-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.travel-reimburse-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.travel-reimburse-form :deep(.el-input-number) {
  width: 100%;
}
.travel-reimburse-form :deep(.el-row) {
  margin-bottom: 0;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,193 @@
import dayjs from "dayjs";
/** è´¹ç”¨ç§‘ç›® */
export const EXPENSE_SUBJECT_OPTIONS = [
  { label: "交通费", value: "transport" },
  { label: "住宿费", value: "hotel" },
  { label: "餐饮费", value: "meal" },
  { label: "其他", value: "other" },
];
const TIER1_CITIES = ["北京", "上海", "广州", "深圳"];
export function expenseSubjectLabel(v) {
  return EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === v)?.label || "—";
}
export function statusLabel(v) {
  if (v === "approved") return "通过";
  if (v === "rejected") return "驳回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "approved") return "success";
  if (v === "rejected") return "danger";
  return "warning";
}
export function detectTravelTier(destination) {
  const city = (destination || "").trim();
  if (!city) return "tier3";
  if (TIER1_CITIES.some((c) => city.includes(c))) return "tier1";
  const tier2Keywords = ["杭州", "南京", "武汉", "成都", "重庆", "西安", "天津", "苏州", "长沙", "郑州"];
  if (tier2Keywords.some((c) => city.includes(c))) return "tier2";
  return "tier3";
}
export function getTravelStandardByTier(tier) {
  const map = {
    tier1: { hotelPerNight: 600, transportPerDay: 80, mealPerDay: 100, label: "一线城市" },
    tier2: { hotelPerNight: 450, transportPerDay: 60, mealPerDay: 80, label: "二线城市" },
    tier3: { hotelPerNight: 350, transportPerDay: 40, mealPerDay: 60, label: "其他城市" },
  };
  return map[tier] || map.tier3;
}
export function computeTravelDays(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  const days = Math.ceil(t1.diff(t0, "day", true));
  return Math.max(1, days);
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: undefined,
    description: "",
  };
}
export function createEmptyForm() {
  return {
    id: undefined,
    reimburseNo: "",
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    reimburseReason: "",
    travelStartTime: "",
    travelEndTime: "",
    travelDays: undefined,
    departurePlace: "",
    destination: "",
    hotelStandard: undefined,
    hotelDays: undefined,
    livingSubsidy: undefined,
    applyAmount: undefined,
    payee: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [],
    currentNodeIndex: 0,
    needSpecialApproval: false,
    deptId: "",
    deptName: "",
    travelTier: "tier3",
  };
}
export function initApprovalFlowNodes(nodes) {
  return (nodes || []).map((n, i) => ({
    ...n,
    sortOrder: i + 1,
    nodeOrder: i + 1,
    nodeStatus: i === 0 ? "process" : "wait",
    approveOpinion: n.approveOpinion || "",
    approveTime: n.approveTime || "",
  }));
}
export function advanceApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult };
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  nodes[idx] = {
    ...nodes[idx],
    nodeStatus: "finish",
    approveOpinion: opinion || "同意",
    approveTime: now,
  };
  const next = idx + 1;
  if (next >= nodes.length) {
    return { nodes, currentNodeIndex: idx, approvalResult: "approved" };
  }
  nodes[next] = { ...nodes[next], nodeStatus: "process" };
  return { nodes, currentNodeIndex: next, approvalResult: "pending" };
}
export function rejectApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  if (nodes[idx]) {
    nodes[idx] = {
      ...nodes[idx],
      nodeStatus: "error",
      approveOpinion: opinion || "驳回",
      approveTime: now,
    };
  }
  return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: opinion || "驳回" };
}
/** æ¨¡æ‹Ÿéƒ¨é—¨é¢„算(与预算系统联动占位) */
export function mockDeptBudget(deptId) {
  const id = String(deptId || "default");
  let s = 0;
  for (let i = 0; i < id.length; i++) s += id.charCodeAt(i);
  const total = 500000 + (s % 200) * 1000;
  const used = (s % 80) * 3500;
  return {
    deptId: id,
    totalBudget: total,
    usedAmount: used,
    remainingAmount: Math.max(0, total - used),
  };
}
export function normalizeImportedRow(raw, idx) {
  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
  const travelDays =
    raw.travelDays != null
      ? Number(raw.travelDays)
      : computeTravelDays(raw.travelStartTime, raw.travelEndTime);
  return {
    id,
    reimburseNo: raw.reimburseNo || `TR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`,
    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
    employeeNo: raw.employeeNo ?? raw.applicantNo ?? "",
    employeeName: raw.employeeName ?? raw.applicantName ?? "未知",
    applicantNo: raw.employeeNo ?? raw.applicantNo ?? "",
    applicantName: raw.employeeName ?? raw.applicantName ?? "未知",
    reimburseReason: raw.reimburseReason ?? "",
    travelStartTime: raw.travelStartTime ?? "",
    travelEndTime: raw.travelEndTime ?? "",
    travelDays: travelDays == null || Number.isNaN(travelDays) ? 1 : travelDays,
    departurePlace: raw.departurePlace ?? "",
    destination: raw.destination ?? "",
    hotelStandard: raw.hotelStandard,
    hotelDays: raw.hotelDays,
    livingSubsidy: raw.livingSubsidy,
    applyAmount: raw.applyAmount ?? 0,
    payee: raw.payee ?? "",
    expenseDetails: Array.isArray(raw.expenseDetails) ? raw.expenseDetails : [],
    invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [],
    approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) ? raw.approvalFlowNodes : [],
    currentNodeIndex: raw.currentNodeIndex ?? 0,
    approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending",
    rejectReason: raw.rejectReason ?? "",
    approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [],
    needSpecialApproval: !!raw.needSpecialApproval,
    deptId: raw.deptId ?? "",
    deptName: raw.deptName ?? "",
    travelTier: raw.travelTier || detectTravelTier(raw.destination),
    createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
  };
}