From 19f2e3bdbe04e7ea79c6a0bdc8c7318d4837b189 Mon Sep 17 00:00:00 2001
From: gongchunyi <deslre0381@gmail.com>
Date: 星期四, 28 五月 2026 17:36:45 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/dev_NEW_pro' into dev_pro_山西_晋和园

---
 src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js |  634 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 634 insertions(+), 0 deletions(-)

diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
new file mode 100644
index 0000000..3b90a3b
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
@@ -0,0 +1,634 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import {
+  deleteFinReimbursement,
+  getFinReimbursementDetail,
+  listFinReimbursementPage,
+  persistFinReimbursement,
+} from "@/api/officeProcessAutomation/finReimbursement.js";
+import { ElMessageBox } from "element-plus";
+import { userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from "vue";
+import {
+  buildCostReimbursementSaveDto,
+  buildFinReimbursementListParams,
+  filterReimbursementRowsBySearch,
+  hasActiveReimbursementSearch,
+  canDeleteReimbursementRow,
+  canEditReimbursementRow,
+  enrichReimbursementListRowsWithApprovalFlow,
+  filterRowsByReimbursementType,
+  FIN_REIMBURSEMENT_TYPE,
+  mapCostReimbursementRow,
+  mapFinReimbursementDetailRow,
+  resolveReimbursementDeleteId,
+  unwrapFinReimbursementDetail,
+  unwrapFinReimbursementPage,
+  validateReimbursementApprovalNodes,
+  validateReimbursementPersistDto,
+} from "../shared/finReimbursementMappers.js";
+import { consumeReimburseEditFromApprove } from "../shared/reimburseApproveBridge.js";
+import {
+  EXPENSE_CATEGORY_OPTIONS,
+  CATEGORY_TEMPLATES,
+  EXPENSE_SUBJECT_OPTIONS,
+  expenseCategoryLabel,
+  expenseSubjectLabel,
+  statusLabel,
+  statusTagType,
+  formatApprovalFlowSummary,
+  buildAutoApprovalFlow,
+  getApprovalRuleHint,
+  createEmptyExpenseDetail,
+  createEmptyForm,
+  applyCategoryTemplate,
+  initApprovalFlowNodes,
+  advanceApprovalFlow,
+  rejectApprovalFlow,
+  normalizeImportedRow,
+} from "./costReimburseUtils.js";
+
+function unwrapArray(payload) {
+  if (Array.isArray(payload)) return payload;
+  if (payload?.data && Array.isArray(payload.data)) return payload.data;
+  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
+  return [];
+}
+
+function isActiveUser(u) {
+  if (u.delFlag === "2" || u.delFlag === 2) return false;
+  if (u.status == null) return true;
+  return String(u.status) === "0";
+}
+
+function demoFlowNodes(amount = 1200, category = "transport") {
+  return buildAutoApprovalFlow(amount, category);
+}
+
+export function useCostReimburse() {
+  const { proxy } = getCurrentInstance();
+
+  const allRows = ref([]);
+
+  const searchForm = reactive({
+    applicantKeyword: "",
+  });
+  const tableLoading = ref(false);
+  const page = reactive({ current: 1, size: 10, total: 0 });
+  const importInputRef = ref(null);
+  const allUsersCache = ref([]);
+  const applicantFormSearchLoading = ref(false);
+  const applicantFormOptions = ref([]);
+  const formRef = ref();
+  const form = reactive(createEmptyForm());
+  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
+  const detailDialog = reactive({ visible: false });
+  const detailLoading = ref(false);
+  const detailRow = ref({});
+  const approveDialog = reactive({ visible: false, row: null });
+  const approveOpinion = ref("");
+  const submitSaving = ref(false);
+
+  const tableData = computed(() =>
+    allRows.value.map((r) => ({
+      ...r,
+      approvalFlowSummary: formatApprovalFlowSummary(r),
+    }))
+  );
+
+  async function fetchList() {
+    tableLoading.value = true;
+    try {
+      const res = await listFinReimbursementPage(
+        buildFinReimbursementListParams({
+          page,
+          searchForm,
+          reimbursementType: FIN_REIMBURSEMENT_TYPE.COST,
+        })
+      );
+      const { records, total } = unwrapFinReimbursementPage(res);
+      const filtered = filterRowsByReimbursementType(
+        records,
+        FIN_REIMBURSEMENT_TYPE.COST
+      );
+      let mapped = filtered.map(mapCostReimbursementRow);
+      mapped = await enrichReimbursementListRowsWithApprovalFlow(
+        mapped,
+        FIN_REIMBURSEMENT_TYPE.COST
+      );
+      if (hasActiveReimbursementSearch(searchForm)) {
+        mapped = filterReimbursementRowsBySearch(mapped, searchForm);
+      }
+      allRows.value = mapped;
+      const dropped = records.length - filtered.length;
+      let nextTotal =
+        dropped > 0 ? Math.max(0, Number(total) - dropped) : Number(total);
+      if (hasActiveReimbursementSearch(searchForm)) {
+        nextTotal = mapped.length;
+      }
+      page.total = nextTotal;
+    } catch {
+      allRows.value = [];
+      page.total = 0;
+      proxy?.$modal?.msgError?.("璐圭敤鎶ラ攢鍒楄〃鍔犺浇澶辫触");
+    } finally {
+      tableLoading.value = false;
+    }
+  }
+
+  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
+
+  const detailTotalAmount = computed(() => {
+    const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
+    return Math.round(sum * 100) / 100;
+  });
+
+  const approvalRuleHint = computed(() =>
+    getApprovalRuleHint(form.applyAmount ?? detailTotalAmount.value, form.expenseCategory)
+  );
+
+  const tableColumn = ref([
+    { label: "鎶ラ攢鍗曞彿", prop: "reimburseNo", width: 150 },
+    { label: "鐢宠浜虹紪鍙�", prop: "applicantNo", width: 110 },
+    { label: "鐢宠浜�", prop: "applicantName", minWidth: 90 },
+    { label: "鎶ラ攢閲戦(鍏�)", prop: "applyAmount", width: 110 },
+    { label: "鎶ラ攢鍘熷洜", prop: "reimburseReason", minWidth: 160, showOverflowTooltip: true },
+    { label: "鐢宠鏃堕棿", prop: "applyTime", width: 165 },
+    { label: "鍒涘缓鏃堕棿", prop: "createTime", width: 165 },
+    {
+      label: "鎶ラ攢鐘舵��",
+      prop: "approvalResult",
+      width: 100,
+      dataType: "tag",
+      formatData: (v) => statusLabel(v),
+      formatType: (v) => statusTagType(v),
+    },
+    {
+      label: "瀹℃壒娴佺▼",
+      prop: "approvalFlowSummary",
+      minWidth: 200,
+      showOverflowTooltip: true,
+    },
+    {
+      dataType: "action",
+      label: "鎿嶄綔",
+      align: "center",
+      fixed: "right",
+      width: 220,
+      operation: [
+        {
+          name: "缂栬緫",
+          type: "text",
+          disabled: (row) => !canEditReimbursementRow(row),
+          clickFun: (row) => openFormDialog("edit", row),
+        },
+        { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+        {
+          name: "鍒犻櫎",
+          type: "danger",
+          disabled: (row) => !canDeleteReimbursementRow(row),
+          clickFun: (row) => confirmRemoveRow(row),
+        },
+      ],
+    },
+  ]);
+
+  const formRules = {
+    applicantId: [{ required: true, message: "璇烽�夋嫨鍛樺伐", trigger: "change" }],
+    expenseCategory: [{ required: true, message: "璇烽�夋嫨璐圭敤绫诲瀷", trigger: "change" }],
+    reimburseReason: [{ required: true, message: "璇峰~鍐欐姤閿�鍘熷洜", trigger: "blur" }],
+    applyAmount: [{ required: true, message: "璇峰~鍐欐姤閿�閲戦", trigger: "blur" }],
+    payee: [{ required: true, message: "璇峰~鍐欐敹娆句汉", trigger: "blur" }],
+    payeeAccount: [{ required: true, message: "璇峰~鍐欐敹娆捐处鍙�", trigger: "blur" }],
+    bankBranch: [{ required: true, message: "璇峰~鍐欏紑鎴锋敮琛�", trigger: "blur" }],
+    approvalFlowNodes: [
+      {
+        validator: (_r, _v, cb) => {
+          const nodes = form.approvalFlowNodes || [];
+          if (!nodes.length) {
+            cb(new Error("璇疯嚦灏戦厤缃竴涓鎵硅妭鐐�"));
+            return;
+          }
+          if (nodes.some((n) => n.approverId == null || n.approverId === "")) {
+            cb(new Error("姣忎釜鑺傜偣椤婚�夋嫨瀹℃壒浜�"));
+            return;
+          }
+          cb();
+        },
+        trigger: "change",
+      },
+    ],
+  };
+
+  async function loadUserPool() {
+    try {
+      allUsersCache.value = unwrapArray(await userListNoPageByTenantId());
+    } catch {
+      allUsersCache.value = [];
+    }
+  }
+
+  function userSelectLabel(u) {
+    const nick = u.nickName || "";
+    const name = u.userName || "";
+    if (nick && name && nick !== name) return `${nick}锛�${name}锛塦;
+    return nick || name || `鐢ㄦ埛${u.userId ?? u.id ?? ""}`;
+  }
+
+  function userById(id) {
+    return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
+  }
+
+  function employeeNoFromUser(u) {
+    if (!u) return "";
+    return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : "");
+  }
+
+  function filterUsersByQuery(query) {
+    const list = allUsersCache.value.filter(isActiveUser);
+    const q = (query || "").trim().toLowerCase();
+    if (!q) return [...list];
+    return list.filter((u) => {
+      const nick = (u.nickName || "").toLowerCase();
+      const uname = (u.userName || "").toLowerCase();
+      return nick.includes(q) || uname.includes(q);
+    });
+  }
+
+  async function remoteSearchApplicantForm(query) {
+    applicantFormSearchLoading.value = true;
+    try {
+      if (!allUsersCache.value.length) await loadUserPool();
+      applicantFormOptions.value = filterUsersByQuery(query);
+    } finally {
+      applicantFormSearchLoading.value = false;
+    }
+  }
+
+  function onApplicantChange(uid) {
+    const u = userById(uid);
+    if (u) {
+      form.employeeName = u.nickName || u.userName || "";
+      form.employeeNo = employeeNoFromUser(u);
+      form.payee = form.payee || form.employeeName;
+      form.deptId = String(u.deptId ?? u.sysDeptId ?? "");
+      form.deptName = u.dept?.deptName ?? u.deptName ?? "";
+    } else {
+      form.employeeName = "";
+      form.employeeNo = "";
+    }
+  }
+
+  function autoAssignApprovalFlow() {
+    const amount = Number(form.applyAmount) || detailTotalAmount.value || 0;
+    form.approvalFlowNodes = buildAutoApprovalFlow(
+      amount,
+      form.expenseCategory || "other",
+      form.approvalFlowNodes
+    );
+    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
+  }
+
+  function onExpenseCategoryChange(val) {
+    if (val && !(form.expenseDetails || []).length) {
+      applyCategoryTemplate(form, val);
+      syncApplyAmountFromDetails();
+    }
+    autoAssignApprovalFlow();
+  }
+
+  function applyTemplate(category) {
+    applyCategoryTemplate(form, category);
+    syncApplyAmountFromDetails();
+    autoAssignApprovalFlow();
+    proxy?.$modal?.msgSuccess?.(`宸插簲鐢ㄣ��${CATEGORY_TEMPLATES[category]?.label || category}銆嶅~鎶ユā鏉縛);
+  }
+
+  function onDetailAmountChange() {
+    syncApplyAmountFromDetails();
+    autoAssignApprovalFlow();
+  }
+
+  function onApprovalFlowChange() {
+    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
+  }
+
+  function addExpenseDetail() {
+    form.expenseDetails.push(createEmptyExpenseDetail());
+  }
+
+  function removeExpenseDetail(index) {
+    form.expenseDetails.splice(index, 1);
+    syncApplyAmountFromDetails();
+    autoAssignApprovalFlow();
+  }
+
+  function syncApplyAmountFromDetails() {
+    form.applyAmount = detailTotalAmount.value;
+  }
+
+  function handleQuery() {
+    page.current = 1;
+    return fetchList();
+  }
+
+  function resetSearch() {
+    searchForm.applicantKeyword = "";
+    handleQuery();
+  }
+
+  function pagination(obj) {
+    page.current = obj.page;
+    page.size = obj.limit;
+    return fetchList();
+  }
+
+  async function loadCostDetailRow(row) {
+    const id = resolveReimbursementDeleteId(row);
+    if (id == null) {
+      throw new Error("missing id");
+    }
+    const res = await getFinReimbursementDetail(id);
+    const raw = unwrapFinReimbursementDetail(res);
+    return mapFinReimbursementDetailRow(raw, FIN_REIMBURSEMENT_TYPE.COST);
+  }
+
+  async function openDetail(row) {
+    const id = resolveReimbursementDeleteId(row);
+    if (id == null) {
+      proxy?.$modal?.msgWarning?.("鏃犳硶鏌ョ湅璇︽儏锛氱己灏戞姤閿�鍗� ID");
+      return;
+    }
+    detailDialog.visible = true;
+    detailLoading.value = true;
+    detailRow.value = { ...row };
+    try {
+      detailRow.value = await loadCostDetailRow(row);
+    } catch {
+      proxy?.$modal?.msgError?.("鍔犺浇璇︽儏澶辫触");
+      detailDialog.visible = false;
+    } finally {
+      detailLoading.value = false;
+    }
+  }
+
+  async function confirmRemoveRow(row) {
+    const id = resolveReimbursementDeleteId(row);
+    if (id == null) {
+      proxy?.$modal?.msgWarning?.("鏃犳硶鍒犻櫎锛氱己灏戞姤閿�鍗� ID");
+      return;
+    }
+    const title = row.reimburseNo || row.billNo || row.reimburseReason || "璇ユ姤閿�鍗�";
+    try {
+      await ElMessageBox.confirm(
+        `纭畾瑕佸垹闄ゃ��${title}銆嶅悧锛熷垹闄ゅ悗涓嶅彲鎭㈠銆俙,
+        "鍒犻櫎纭",
+        {
+          type: "warning",
+          confirmButtonText: "纭畾鍒犻櫎",
+          cancelButtonText: "鍙栨秷",
+          distinguishCancelAndClose: true,
+          autofocus: false,
+        }
+      );
+    } catch {
+      return;
+    }
+    try {
+      await deleteFinReimbursement([id]);
+      proxy?.$modal?.msgSuccess?.("鍒犻櫎鎴愬姛");
+      if (detailDialog.visible && resolveReimbursementDeleteId(detailRow.value) === id) {
+        detailDialog.visible = false;
+      }
+      await handleQuery();
+    } catch {
+      proxy?.$modal?.msgError?.("鍒犻櫎澶辫触");
+    }
+  }
+
+  function openApprove(row) {
+    approveDialog.row = { ...row };
+    approveDialog.visible = true;
+  }
+
+  function approvalActionLabel(v) {
+    if (v === "approved") return "閫氳繃";
+    if (v === "rejected") return "椹冲洖";
+    return "鎻愪氦";
+  }
+
+  async function openFormDialog(mode, row) {
+    formDialog.mode = mode;
+    formDialog.readonly = false;
+    formDialog.title = mode === "add" ? "鏂板璐圭敤鎶ラ攢" : "缂栬緫璐圭敤鎶ラ攢";
+    if (!allUsersCache.value.length) await loadUserPool();
+    Object.assign(form, createEmptyForm());
+    if (mode === "edit" && row) {
+      let editRow = row;
+      try {
+        editRow = await loadCostDetailRow(row);
+      } catch {
+        proxy?.$modal?.msgError?.("鍔犺浇鎶ラ攢璇︽儏澶辫触");
+        return;
+      }
+      Object.assign(form, {
+        ...JSON.parse(JSON.stringify(editRow)),
+        reimbursementId: editRow.reimbursementId ?? editRow.id,
+        attachmentList: JSON.parse(JSON.stringify(editRow.attachmentList || editRow.invoiceAttachments || [])),
+        approvalFlowNodes: JSON.parse(JSON.stringify(editRow.approvalFlowNodes || [])),
+        expenseDetails: JSON.parse(JSON.stringify(editRow.expenseDetails || [])),
+      });
+      const u = userById(editRow.applicantId);
+      applicantFormOptions.value = u
+        ? [u]
+        : [{ userId: editRow.applicantId, nickName: editRow.employeeName, userName: editRow.employeeNo }];
+    } else {
+      form.approvalFlowNodes = buildAutoApprovalFlow(0, "other");
+      remoteSearchApplicantForm("");
+    }
+    formDialog.visible = true;
+    nextTick(() => {
+      formRef.value?.clearValidate?.();
+    });
+  }
+
+  function onFormClosed() {
+    formRef.value?.resetFields?.();
+  }
+
+  async function submitForm() {
+    try {
+      await formRef.value?.validate?.();
+    } catch {
+      return;
+    }
+    if (!(form.expenseDetails || []).length) {
+      proxy?.$modal?.msgWarning?.("璇疯嚦灏戞坊鍔犱竴鏉℃姤閿�鏄庣粏");
+      return;
+    }
+    syncApplyAmountFromDetails();
+
+    if (submitSaving.value) return;
+    const isEdit = formDialog.mode === "edit";
+    const dto = buildCostReimbursementSaveDto(form);
+    const check = validateReimbursementPersistDto(dto, isEdit);
+    if (!check.ok) {
+      proxy?.$modal?.msgWarning?.(check.message);
+      return;
+    }
+    const nodeCheck = validateReimbursementApprovalNodes(dto);
+    if (!nodeCheck.ok) {
+      proxy?.$modal?.msgWarning?.(nodeCheck.message);
+      return;
+    }
+    submitSaving.value = true;
+    try {
+      await persistFinReimbursement(dto, isEdit);
+      proxy?.$modal?.msgSuccess?.(isEdit ? "淇濆瓨鎴愬姛" : "鎻愪氦鎴愬姛");
+      formDialog.visible = false;
+      await handleQuery();
+    } catch {
+      proxy?.$modal?.msgError?.(isEdit ? "淇濆瓨澶辫触" : "鎻愪氦澶辫触");
+    } finally {
+      submitSaving.value = false;
+    }
+  }
+
+  async function submitApprove(result) {
+    const row = approveDialog.row;
+    if (!row) return;
+    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
+      proxy?.$modal?.msgWarning?.("椹冲洖椤诲~鍐欏鎵规剰瑙侊紙濡傦細鍙戠エ妯$硦闇�閲嶄紶锛�");
+      return;
+    }
+    const idx = allRows.value.findIndex((r) => r.id === row.id);
+    if (idx === -1) return;
+    const cur = allRows.value[idx];
+    const operatorName = "褰撳墠瀹℃壒浜�";
+    const record = {
+      operatorName,
+      result,
+      opinion: approveOpinion.value || (result === "approved" ? "鍚屾剰" : "椹冲洖"),
+      time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+    };
+    const records = [...(cur.approvalRecords || []), record];
+    let flowUpdate;
+    if (result === "approved") {
+      flowUpdate = advanceApprovalFlow(cur, approveOpinion.value);
+    } else {
+      flowUpdate = rejectApprovalFlow(cur, approveOpinion.value);
+    }
+    allRows.value[idx] = {
+      ...cur,
+      approvalFlowNodes: flowUpdate.nodes,
+      currentNodeIndex: flowUpdate.currentNodeIndex,
+      approvalResult: flowUpdate.approvalResult,
+      rejectReason: flowUpdate.rejectReason ?? cur.rejectReason,
+      approvalRecords: records,
+    };
+    proxy?.$modal?.msgSuccess?.(result === "approved" ? "宸查�氳繃" : "宸查┏鍥�");
+    approveDialog.visible = false;
+    handleQuery();
+  }
+
+  function handleExport() {
+    const data = allRows.value;
+    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = `璐圭敤鎶ラ攢瀵煎嚭_${dayjs().format("YYYYMMDDHHmmss")}.json`;
+    a.click();
+    URL.revokeObjectURL(url);
+    proxy?.$modal?.msgSuccess?.(`宸插鍑� ${data.length} 鏉);
+  }
+
+  function handleImportClick() {
+    importInputRef.value?.click?.();
+  }
+
+  function onImportFile(e) {
+    const file = e.target.files?.[0];
+    e.target.value = "";
+    if (!file) return;
+    const reader = new FileReader();
+    reader.onload = () => {
+      try {
+        const parsed = JSON.parse(String(reader.result || ""));
+        const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
+        if (!Array.isArray(arr) || !arr.length) {
+          proxy?.$modal?.msgWarning?.("瀵煎叆鏍煎紡椤讳负璐圭敤鎶ラ攢 JSON 鏁扮粍");
+          return;
+        }
+        arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i)));
+        proxy?.$modal?.msgSuccess?.(`鎴愬姛瀵煎叆 ${arr.length} 鏉);
+        handleQuery();
+      } catch {
+        proxy?.$modal?.msgError?.("瑙f瀽澶辫触");
+      }
+    };
+    reader.readAsText(file, "utf-8");
+  }
+
+  onMounted(async () => {
+    loadUserPool();
+    await fetchList();
+    const editPayload = consumeReimburseEditFromApprove();
+    if (editPayload?.reimbursementId != null) {
+      await openFormDialog("edit", { reimbursementId: editPayload.reimbursementId });
+    }
+  });
+
+  return {
+    Search,
+    EXPENSE_CATEGORY_OPTIONS,
+    CATEGORY_TEMPLATES,
+    EXPENSE_SUBJECT_OPTIONS,
+    expenseCategoryLabel,
+    expenseSubjectLabel,
+    searchForm,
+    tableLoading,
+    page,
+    tableData,
+    tableColumn,
+    importInputRef,
+    formRef,
+    form,
+    formDialog,
+    formRules,
+    detailDialog,
+    detailLoading,
+    detailRow,
+    approveDialog,
+    approveOpinion,
+    applicantFormSearchLoading,
+    applicantFormOptions,
+    flowUserOptions,
+    detailTotalAmount,
+    approvalRuleHint,
+    handleQuery,
+    resetSearch,
+    pagination,
+    remoteSearchApplicantForm,
+    userSelectLabel,
+    onApplicantChange,
+    onExpenseCategoryChange,
+    applyTemplate,
+    onDetailAmountChange,
+    onApprovalFlowChange,
+    addExpenseDetail,
+    removeExpenseDetail,
+    syncApplyAmountFromDetails,
+    autoAssignApprovalFlow,
+    openFormDialog,
+    onFormClosed,
+    submitForm,
+    submitSaving,
+    openDetail,
+    approvalActionLabel,
+    submitApprove,
+    handleExport,
+    handleImportClick,
+    onImportFile,
+  };
+}

--
Gitblit v1.9.3