From a9d97b150701e634bdb751eab277696abd136cca Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期二, 16 六月 2026 14:39:47 +0800
Subject: [PATCH] 君歌app 1.依照web端功能修改

---
 src/pages/oa/_utils/finReimbursementMappers.js | 1058 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 1,058 insertions(+), 0 deletions(-)

diff --git a/src/pages/oa/_utils/finReimbursementMappers.js b/src/pages/oa/_utils/finReimbursementMappers.js
new file mode 100644
index 0000000..9c9a511
--- /dev/null
+++ b/src/pages/oa/_utils/finReimbursementMappers.js
@@ -0,0 +1,1058 @@
+import dayjs from "dayjs";
+import {
+  deleteFinReimbursement,
+  getFinReimbursementDetail,
+  persistFinReimbursement,
+} from "@/api/oa/finReimbursement.js";
+import { APPROVAL_MODULE_KEYS } from "./approvalModuleRegistry.js";
+import { businessStatusClass, normalizeApprovalStatusKey } from "./approveListUtils.js";
+import {
+  EXPENSE_CATEGORY_OPTIONS,
+  expenseTypeToCategory,
+} from "../ReimburseManage/_utils/costReimburseUtils.js";
+import { EXPENSE_SUBJECT_OPTIONS as TRAVEL_EXPENSE_SUBJECTS } from "../ReimburseManage/_utils/travelReimburseUtils.js";
+import { EXPENSE_SUBJECT_OPTIONS as COST_EXPENSE_SUBJECTS } from "../ReimburseManage/_utils/costReimburseUtils.js";
+import { resolveExpenseSubjectLabel } from "../ReimburseManage/_utils/expenseDetailDisplay.js";
+import { mapTasksToFlowNodes } from "./approveListUtils.js";
+import { applyFinReimbursementDetailEnrichment } from "../ReimburseManage/_utils/finReimbursementDetailExtras.js";
+
+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;
+  });
+}
+
+const BILL_STATUS_LABEL = {
+  DRAFT: "鑽夌",
+  IN_APPROVAL: "瀹℃壒涓�",
+  APPROVED: "瀹℃壒閫氳繃",
+  REJECTED: "瀹℃壒椹冲洖",
+  WITHDRAWN: "宸叉挙鍥�",
+  PAID: "宸蹭粯娆�",
+};
+
+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 瑙e寘 */
+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;
+}
+
+export function mapBillStatusToApprovalKey(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 "approved";
+  return normalizeApprovalStatusKey(billStatus);
+}
+
+export function billStatusLabel(billStatus) {
+  const upper = String(billStatus ?? "").trim().toUpperCase();
+  if (BILL_STATUS_LABEL[upper]) return BILL_STATUS_LABEL[upper];
+  const key = mapBillStatusToApprovalKey(billStatus);
+  if (key === "draft") return "鑽夌";
+  if (key === "approved") return "宸插畬鎴�";
+  if (key === "rejected") return "宸查┏鍥�";
+  if (key === "cancelled") return "宸叉挙鍥�";
+  return "杩涜涓�";
+}
+
+export function billStatusCssClass(item) {
+  return businessStatusClass(
+    mapBillStatusToApprovalKey(item?.billStatus ?? item?.status)
+  );
+}
+
+function pickApplicantQuery(searchForm = {}) {
+  const kw = (searchForm.applicantKeyword || "").trim();
+  if (!kw) return {};
+  const out = { applicantName: kw };
+  if (!/[\u4e00-\u9fa5]/.test(kw)) {
+    out.applicantCode = kw;
+  }
+  return out;
+}
+
+export function hasActiveReimbursementSearch(searchForm = {}) {
+  return Boolean((searchForm?.applicantKeyword || "").trim());
+}
+
+export function filterReimbursementRowsBySearch(rows, searchForm = {}) {
+  const list = Array.isArray(rows) ? rows : [];
+  const kw = (searchForm?.applicantKeyword || "").trim().toLowerCase();
+  if (!kw) return list;
+
+  return list.filter((row) => {
+    const parts = [
+      row.applicantName,
+      row.employeeName,
+      row.applicantNo,
+      row.applicantCode,
+      row.employeeNo,
+    ]
+      .filter((v) => v != null && v !== "")
+      .map((v) => String(v).toLowerCase());
+    return parts.some((p) => p.includes(kw));
+  });
+}
+
+/** 鎵佸钩鍖栦负 Spring GET 鍙粦瀹氱殑 query锛坒inReimbursementDto.xxx锛屽嬁鐢ㄦ柟鎷彿锛� */
+function appendDotNotationQuery(target, prefix, fields) {
+  if (!fields || typeof fields !== "object") return;
+  for (const [key, value] of Object.entries(fields)) {
+    if (value == null || value === "") continue;
+    target[`${prefix}.${key}`] = value;
+  }
+}
+
+export function buildFinReimbursementListParams({
+  page,
+  searchForm,
+  reimbursementType,
+  extraDto = {},
+}) {
+  const dto = {
+    reimbursementType,
+    ...pickApplicantQuery(searchForm),
+    ...(extraDto && typeof extraDto === "object" ? extraDto : {}),
+  };
+
+  const params = {
+    current: page.current,
+    size: page.size,
+    "page.current": page.current,
+    "page.size": page.size,
+    ...dto,
+  };
+  appendDotNotationQuery(params, "finReimbursementDto", dto);
+  return params;
+}
+
+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,
+  };
+}
+
+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");
+}
+
+export function mapFinReimbursementFromApi(row, { reimbursementType, moduleKey } = {}) {
+  if (!row) return {};
+  const type = resolveReimbursementType(
+    row,
+    reimbursementType || getReimbursementTypeByModuleKey(moduleKey)
+  );
+  const isTravel = type === FIN_REIMBURSEMENT_TYPE.TRAVEL;
+  const travel = isTravel ? pickTravelFromRow(row) : {};
+  const apiNodes = resolveRowApiNodes(row);
+  const approvalFlowNodes = mapNodesToFormFlow(apiNodes);
+  const flowSummary = formatApprovalFlowSummary({
+    ...row,
+    nodes: apiNodes,
+    approvalFlowNodes,
+  });
+  const instanceId = row.approvalInstanceId ?? row.id;
+
+  return {
+    ...row,
+    reimbursementId: row.id,
+    id: instanceId,
+    approvalInstanceId: row.approvalInstanceId,
+    instanceNo: row.billNo || "",
+    billNo: row.billNo || "",
+    reimbursementType: type,
+    reimbursementTypeLabel: reimbursementTypeLabel(type),
+    moduleKey: getModuleKeyByReimbursementType(type),
+    applicantNo: row.applicantCode || "",
+    applicantCode: row.applicantCode || "",
+    applicantName: row.applicantName || "",
+    reason: row.reason || "",
+    expenseType: row.expenseType || "",
+    applyAmount: row.applyAmount,
+    billStatus: row.billStatus,
+    status: row.billStatus,
+    approvalStatus: mapBillStatusToApprovalKey(row.billStatus),
+    title: row.reason || row.billNo || "",
+    summary: row.reason || row.billNo || "",
+    createTime: formatReimbursementDateTime(row.createTime),
+    departurePlace: travel.departureCity || "",
+    destination: travel.destinationCity || "",
+    travelStartTime: formatReimbursementDateTime(travel.startTime),
+    travelEndTime: formatReimbursementDateTime(travel.endTime),
+    travel,
+    details: row.details || [],
+    nodes: apiNodes,
+    flowNodes: apiNodes,
+    approvalFlowNodes,
+    approvalFlowSummary: flowSummary,
+    displayRows: buildFinReimbursementDisplayRows(
+      {
+        billNo: row.billNo,
+        applyAmount: row.applyAmount,
+        billStatus: row.billStatus,
+        departurePlace: travel.departureCity,
+        destination: travel.destinationCity,
+        expenseType: row.expenseType,
+        reason: row.reason,
+        approvalFlowSummary: flowSummary,
+      },
+      type
+    ),
+  };
+}
+
+export function buildFinReimbursementDisplayRows(item, reimbursementType) {
+  const type = normalizeReimbursementType(reimbursementType);
+  const isTravel = type === FIN_REIMBURSEMENT_TYPE.TRAVEL;
+  const rows = [
+    { label: "鎶ラ攢鍗曞彿", value: item.billNo },
+    {
+      label: "鐢宠閲戦",
+      value: item.applyAmount != null ? `${item.applyAmount} 鍏僠 : "",
+    },
+    { label: "鍗曟嵁鐘舵��", value: billStatusLabel(item.billStatus) },
+  ];
+  if (isTravel) {
+    rows.splice(
+      1,
+      0,
+      { label: "鍑哄樊鍦�", value: item.departurePlace },
+      { label: "鐩殑鍦�", value: item.destination }
+    );
+  } else {
+    rows.splice(1, 0, { label: "璐圭敤绫诲瀷", value: item.expenseType });
+  }
+  if (item.reason) {
+    rows.push({ label: "鎶ラ攢鍘熷洜", value: item.reason });
+  }
+  if (item.approvalFlowSummary && item.approvalFlowSummary !== "鈥�") {
+    rows.push({ label: "瀹℃壒娴佺▼", value: item.approvalFlowSummary });
+  }
+  return rows;
+}
+
+/** 淇敼鍦烘櫙蹇呴』甯︿富閿� ID锛堜笌 Web 涓�鑷达級 */
+export function validateReimbursementPersistDto(dto, isEdit) {
+  if (!isEdit) return { ok: true };
+  if (dto?.id != null && dto.id !== "") return { ok: true };
+  return { ok: false, message: "鏃犳硶淇敼锛氱己灏戞姤閿�鍗� ID" };
+}
+
+export { deleteFinReimbursement, getFinReimbursementDetail, persistFinReimbursement };
+
+/** 鍒楄〃琛屼富閿紙鍒犻櫎/淇敼鐢� fin_reimbursement.id锛屽嬁鐢� item.id 瀹℃壒瀹炰緥 ID锛� */
+export function resolveReimbursementDeleteId(row) {
+  const raw = row?.reimbursementId;
+  if (raw == null || raw === "" || String(raw).startsWith("local_")) {
+    return undefined;
+  }
+  const n = Number(raw);
+  return Number.isNaN(n) ? raw : n;
+}
+
+/** 鏄惁鍏佽鍒犻櫎锛堝鎵逛腑銆佸凡閫氳繃銆佸凡浠樻涓嶅彲鍒狅級 */
+export function canDeleteReimbursementRow(row) {
+  const upper = String(row?.billStatus ?? row?.status ?? "").trim().toUpperCase();
+  if (upper === "PAID") return false;
+  const key = mapBillStatusToApprovalKey(
+    row?.billStatus ?? row?.approvalStatus ?? row?.status
+  );
+  return key !== "pending" && key !== "approved";
+}
+
+export function canEditReimbursementRow(row) {
+  return canDeleteReimbursementRow(row);
+}
+
+/** 鎷夊彇鎶ラ攢璇︽儏锛堝惈鏄庣粏銆佸鎵硅妭鐐癸紝涓� Web mapFinReimbursementDetailRow 涓�鑷达級 */
+export async function fetchFinReimbursementListItemDetail(item, reimbursementTypeOrModuleKey) {
+  const id = resolveReimbursementDeleteId(item);
+  if (id == null) {
+    throw new Error("missing reimbursement id");
+  }
+  const res = await getFinReimbursementDetail(id);
+  const raw = unwrapFinReimbursementDetail(res);
+  const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey);
+  const row = mapFinReimbursementDetailRow(raw, type);
+  return {
+    ...row,
+    reimbursementType: type,
+    reimbursementTypeLabel: reimbursementTypeLabel(type),
+    moduleKey: getModuleKeyByReimbursementType(type),
+    displayRows: buildFinReimbursementDisplayRows(
+      {
+        billNo: row.billNo || row.reimburseNo,
+        applyAmount: row.applyAmount,
+        billStatus: row.billStatus,
+        departurePlace: row.departurePlace,
+        destination: row.destination,
+        expenseType: row.expenseCategory || row.expenseType,
+        reason: row.reimburseReason || row.reason,
+      },
+      type
+    ),
+  };
+}
+
+function toNumber(val) {
+  if (val == null || val === "") return undefined;
+  const n = Number(val);
+  return Number.isNaN(n) ? undefined : n;
+}
+
+function mapSignModeToApi(signMode) {
+  return signMode === "or_sign" ? "OR" : "AND";
+}
+
+function expenseSubjectToCategory(subject) {
+  const hit =
+    TRAVEL_EXPENSE_SUBJECTS.find(x => x.value === subject) ||
+    COST_EXPENSE_SUBJECTS.find(x => x.value === subject);
+  return hit?.label || subject || "";
+}
+
+function mapDetailRowFromApi(d, reimbursementType) {
+  const type = normalizeReimbursementType(reimbursementType);
+  const raw = d.expenseCategory ?? d.expenseSubject ?? "";
+  const opts =
+    type === FIN_REIMBURSEMENT_TYPE.TRAVEL
+      ? TRAVEL_EXPENSE_SUBJECTS
+      : COST_EXPENSE_SUBJECTS;
+  const label = resolveExpenseSubjectLabel(raw, {
+    isTravel: type === FIN_REIMBURSEMENT_TYPE.TRAVEL,
+    subjectOptions: opts,
+  });
+  const hit = opts.find(x => x.value === raw || x.label === raw || x.label === label);
+  return {
+    ...d,
+    expenseSubject: hit?.value || raw,
+  };
+}
+
+function expenseCategoryToType(category) {
+  const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.value === category);
+  return hit?.label || category || "";
+}
+
+export function resolveRowApiNodes(row) {
+  if (!row || typeof row !== "object") return [];
+  const list =
+    row.nodes ||
+    row.flowNodes ||
+    row.approveNodes ||
+    row.finReimbursementNodes ||
+    row.nodeList ||
+    [];
+  return Array.isArray(list) ? list : [];
+}
+
+function sortFlowNodesByLevel(nodes = []) {
+  return [...(Array.isArray(nodes) ? nodes : [])].sort((a, b) => {
+    const la = Number(a?.levelNo ?? a?.nodeOrder ?? a?.sortOrder ?? 0);
+    const lb = Number(b?.levelNo ?? b?.nodeOrder ?? b?.sortOrder ?? 0);
+    return la - lb;
+  });
+}
+
+function formatApiNodeApproverLabel(node, index) {
+  if (!node || typeof node !== "object") return "";
+  const approvers = Array.isArray(node.approvers) ? node.approvers : [];
+  const names = approvers
+    .map(a => (a?.approverName || "").trim())
+    .filter(Boolean);
+  if (names.length) return names.join("/");
+  return (node.approverName || "").trim() || `鑺傜偣${index + 1}`;
+}
+
+/** 鎺ュ彛 nodes 鈫� 椤甸潰瀹℃壒娴� */
+export function mapNodesToFormFlow(nodes = []) {
+  return sortFlowNodesByLevel(nodes).map((n, i) => {
+    const approvers = Array.isArray(n.approvers) ? n.approvers : [];
+    const first = approvers[0] || null;
+    const names = approvers
+      .map(a => (a?.approverName || "").trim())
+      .filter(Boolean);
+    return {
+      ...n,
+      nodeOrder: n.levelNo ?? n.nodeOrder ?? i + 1,
+      signMode: String(n.approveType || "").toUpperCase() === "OR" ? "or_sign" : "countersign",
+      approverId:
+        toNumber(first?.approverId ?? n.approverId) ??
+        first?.approverId ??
+        n.approverId ??
+        "",
+      approverName:
+        names.join("銆�") || first?.approverName || n.approverName || "",
+      nodeStatus: n.nodeStatus,
+    };
+  });
+}
+
+function formatTasksToFlowSummary(tasks = []) {
+  const list = sortFlowNodesByLevel(
+    (Array.isArray(tasks) ? tasks : []).map((t, i) => ({
+      levelNo: t.levelNo ?? t.taskLevel ?? i + 1,
+      approverName:
+        (t.approverName || t.operatorName || t.createUserName || "").trim() ||
+        "",
+    }))
+  );
+  const parts = list.map(t => t.approverName).filter(Boolean);
+  return parts.length ? parts.join(" 鈫� ") : "";
+}
+
+function buildApprovalFlowSummaryForRow(row) {
+  const apiNodes = sortFlowNodesByLevel(resolveRowApiNodes(row));
+  let flowNodes =
+    row?.approvalFlowNodes?.length > 0
+      ? sortFlowNodesByLevel(row.approvalFlowNodes)
+      : mapNodesToFormFlow(apiNodes);
+
+  if (!flowNodes.length && apiNodes.length) {
+    const line = apiNodes
+      .map((n, i) => formatApiNodeApproverLabel(n, i))
+      .filter(Boolean)
+      .join(" 鈫� ");
+    if (line) return line;
+  }
+
+  if (!flowNodes.length) {
+    const fromTasks = formatTasksToFlowSummary(row?.tasks);
+    if (fromTasks) return fromTasks;
+    return "鈥�";
+  }
+
+  return flowNodes
+    .map((n, i) => {
+      const name = (n.approverName || "").trim() || `鑺傜偣${i + 1}`;
+      if (n.nodeStatus === "finish") return `${name}鉁揱;
+      if (n.nodeStatus === "error") return `${name}鉁梎;
+      if (n.nodeStatus === "process") return `${name}鈥;
+      return name;
+    })
+    .join(" 鈫� ");
+}
+
+export function formatApprovalFlowSummary(row) {
+  return buildApprovalFlowSummaryForRow(row);
+}
+
+/** listPage 甯镐笉甯﹀畬鏁� nodes锛屽垪琛ㄥ姞杞藉悗缁熶竴鎷夎鎯呰ˉ鍏ㄥ绾у鎵规祦绋� */
+export async function enrichReimbursementListRowsWithApprovalFlow(
+  rows,
+  reimbursementType
+) {
+  const list = Array.isArray(rows) ? rows : [];
+  if (!list.length) return list;
+
+  const needIds = list
+    .map(r => resolveReimbursementDeleteId(r))
+    .filter(id => id != null);
+
+  if (!needIds.length) return list;
+
+  const detailById = new Map();
+  await Promise.all(
+    needIds.map(async id => {
+      try {
+        const res = await getFinReimbursementDetail(id);
+        detailById.set(String(id), unwrapFinReimbursementDetail(res));
+      } catch {
+        /* ignore */
+      }
+    })
+  );
+
+  const mapRow =
+    reimbursementType === FIN_REIMBURSEMENT_TYPE.TRAVEL
+      ? mapTravelReimbursementRow
+      : mapCostReimbursementRow;
+
+  return list.map(row => {
+    const id = resolveReimbursementDeleteId(row);
+    const detail = id != null ? detailById.get(String(id)) : null;
+    if (!detail) return row;
+    return mapRow({
+      ...row,
+      ...detail,
+      id: row.id ?? detail.id,
+      reimbursementId: row.reimbursementId ?? row.id ?? detail.id,
+    });
+  });
+}
+
+/** 琛ㄥ崟涓婄殑瀹℃壒娴侊紙鍏煎 approvalFlowNodes / nodes / flowNodes锛� */
+export function resolveFormApprovalFlowNodes(form) {
+  const list =
+    form?.approvalFlowNodes ?? form?.nodes ?? form?.flowNodes ?? [];
+  return Array.isArray(list) ? list : [];
+}
+
+/** 椤甸潰瀹℃壒鑺傜偣 鈫� 鎺ュ彛 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) => {
+            const item = {
+              approverId: toNumber(a.approverId) ?? a.approverId,
+              approverName: a.approverName || "",
+              sortNo: a.sortNo ?? idx + 1,
+            };
+            if (a.id != null) item.id = a.id;
+            if (a.nodeId != null) item.nodeId = a.nodeId;
+            if (a.templateId != null) item.templateId = a.templateId;
+            else if (templateId != null) item.templateId = templateId;
+            if (a.roleKey) item.roleKey = a.roleKey;
+            return item;
+          });
+      } else if (n.approverId != null && n.approverId !== "") {
+        const item = {
+          approverId: toNumber(n.approverId) ?? n.approverId,
+          approverName: n.approverName || "",
+          sortNo: 1,
+        };
+        if (n.roleKey) item.roleKey = n.roleKey;
+        approvers = [item];
+      }
+      if (!approvers.length) return null;
+      const node = {
+        levelNo: n.levelNo ?? n.nodeOrder ?? n.sortOrder ?? 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;
+      if (n.roleKey) node.roleKey = n.roleKey;
+      return node;
+    })
+    .filter(Boolean);
+}
+
+/** 淇濆瓨鍓嶆牎楠� nodes 宸查厤缃� */
+export function validateReimbursementApprovalNodes(dto) {
+  if (Array.isArray(dto?.nodes) && dto.nodes.length > 0) {
+    return { ok: true };
+  }
+  return { ok: false, message: "璇烽厤缃鎵规祦绋嬪苟閫夋嫨瀹℃壒浜�" };
+}
+
+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;
+}
+
+/** 琛ㄥ崟闄勪欢鍒楄〃锛堝吋瀹瑰绉嶅瓧娈靛悕锛� */
+export function resolveFormAttachmentList(form) {
+  const list =
+    form?.attachmentList ??
+    form?.storageBlobDTOs ??
+    form?.storageBlobVOList ??
+    form?.invoiceAttachments ??
+    [];
+  return Array.isArray(list) ? list : [];
+}
+
+/** 椤甸潰闄勪欢 鈫� 淇濆瓨 DTO锛坰torageBlobVOList / storageBlobDTOs锛� */
+export function mapFormAttachmentsToApi(list = [], reimbursementId) {
+  const rid =
+    reimbursementId != null
+      ? toNumber(reimbursementId) ?? reimbursementId
+      : undefined;
+
+  return (list || [])
+    .map((item, i) => {
+      if (!item) return null;
+      const url =
+        item.url ||
+        item.fileUrl ||
+        item.downloadUrl ||
+        item.downloadURL ||
+        item.previewUrl ||
+        item.previewURL ||
+        item.link ||
+        "";
+      const name =
+        item.fileName ||
+        item.originalFilename ||
+        item.originalFileName ||
+        item.blobName ||
+        item.name ||
+        `闄勪欢${i + 1}`;
+
+      const idRaw = item.id ?? item.blobId;
+      const isTempId =
+        idRaw != null &&
+        /^(inv_|att_|ed_|local_)/.test(String(idRaw));
+
+      if (!url && (idRaw == null || isTempId)) return null;
+
+      const blob = {
+        fileName: name,
+        originalFilename: name,
+        fileUrl: url || undefined,
+        url: url || undefined,
+      };
+
+      if (idRaw != null && !isTempId) {
+        const n = toNumber(idRaw);
+        blob.id = n != null ? n : idRaw;
+        blob.blobId = blob.id;
+      }
+      if (rid != null) blob.reimbursementId = rid;
+      return blob;
+    })
+    .filter(Boolean);
+}
+
+function applyStorageBlobsToSaveDto(dto, form) {
+  const blobs = mapFormAttachmentsToApi(
+    resolveFormAttachmentList(form),
+    dto?.id ?? form?.reimbursementId ?? form?.id
+  );
+  if (blobs.length) {
+    dto.storageBlobVOList = blobs;
+    dto.storageBlobDTOs = blobs;
+  }
+  return dto;
+}
+
+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;
+    });
+  }
+  const blobLists = [dto.storageBlobVOList, dto.storageBlobDTOs].filter(Array.isArray);
+  blobLists.forEach(list => {
+    list.forEach(b => {
+      b.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;
+}
+
+/** 鎺ュ彛琛� 鈫� 宸梾鎶ラ攢琛ㄥ崟琛� */
+export function mapTravelReimbursementRow(row) {
+  if (!row) return {};
+  const travel = pickTravelFromRow(row);
+  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 || "",
+    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,
+    expenseDetails: details.map(d =>
+      mapDetailRowFromApi(d, FIN_REIMBURSEMENT_TYPE.TRAVEL)
+    ),
+    travel:
+      row.travel && typeof row.travel === "object" && Object.keys(row.travel).length
+        ? row.travel
+        : travel,
+    details,
+    nodes: resolveRowApiNodes(row),
+    approvalFlowNodes: mapNodesToFormFlow(resolveRowApiNodes(row)),
+    tasks: row.tasks || [],
+    approvalFlowSummary: formatApprovalFlowSummary(row),
+    attachmentList: row.attachmentList || row.invoiceAttachments || [],
+  };
+}
+
+/** 鎺ュ彛琛� 鈫� 璐圭敤鎶ラ攢琛ㄥ崟琛� */
+export function mapCostReimbursementRow(row) {
+  if (!row) return {};
+  const details = Array.isArray(row.details) ? row.details : [];
+  const apiNodes = resolveRowApiNodes(row);
+  const approvalFlowNodes = mapNodesToFormFlow(apiNodes);
+  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: expenseTypeToCategory(row.expenseType),
+    applyAmount: row.applyAmount,
+    applyTime: formatReimbursementDateTime(row.createTime),
+    createTime: formatReimbursementDateTime(row.createTime),
+    payee: row.payeeName || "",
+    payeeAccount: row.payeeAccount || "",
+    bankBranch: row.payeeBank || "",
+    payeeBank: row.payeeBank || "",
+    billStatus: row.billStatus,
+    expenseDetails: details.map(d =>
+      mapDetailRowFromApi(d, FIN_REIMBURSEMENT_TYPE.COST)
+    ),
+    details,
+    nodes: apiNodes,
+    approvalFlowNodes,
+    tasks: row.tasks || [],
+    approvalFlowSummary: formatApprovalFlowSummary({
+      ...row,
+      nodes: apiNodes,
+      approvalFlowNodes,
+    }),
+    attachmentList: row.attachmentList || row.invoiceAttachments || [],
+  };
+}
+
+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 || {};
+  }
+
+  let formApprovalFlowNodes = mapNodesToFormFlow(resolveRowApiNodes(raw));
+  if (!formApprovalFlowNodes.length && Array.isArray(raw?.tasks) && raw.tasks.length) {
+    formApprovalFlowNodes = mapNodesToFormFlow(mapTasksToFlowNodes(raw.tasks));
+  }
+
+  const enriched = applyFinReimbursementDetailEnrichment(mapped, raw);
+  return {
+    ...enriched,
+    approvalFlowNodes: formApprovalFlowNodes.length
+      ? formApprovalFlowNodes
+      : enriched.approvalFlowNodes,
+    reimbursementType: type,
+    reimbursementTypeLabel: reimbursementTypeLabel(type),
+    moduleKey: getModuleKeyByReimbursementType(type),
+  };
+}
+
+/** 宸梾琛ㄥ崟 鈫� 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(
+      resolveFormApprovalFlowNodes(form),
+      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);
+
+  applyStorageBlobsToSaveDto(dto, form);
+  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(
+      resolveFormApprovalFlowNodes(form),
+      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);
+
+  applyStorageBlobsToSaveDto(dto, form);
+  return applyReimbursementRelations(dto);
+}
+
+/** 濉姤椤靛姞杞借鎯咃紙涓� Web openFormDialog edit 涓�鑷达級 */
+export async function fetchFinReimbursementFormDetail(item, moduleKey) {
+  const id = resolveReimbursementDeleteId(item);
+  if (id == null) throw new Error("missing reimbursement id");
+  const res = await getFinReimbursementDetail(id);
+  const raw = unwrapFinReimbursementDetail(res);
+  return mapFinReimbursementDetailRow(raw, moduleKey);
+}

--
Gitblit v1.9.3