| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- è´¹ç¨æ¥éï¼è¯¦æ
åªè¯»é¢æ¿ --> |
| | | <template> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="æ¥éåå·">{{ row.reimburseNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¥éç¶æ"> |
| | | <el-tag :type="statusTagType(row.approvalResult)" size="small">{{ statusLabel(row.approvalResult) }}</el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="è´¹ç¨ç±»å">{{ expenseCategoryLabel(row.expenseCategory) }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·æ¶é´">{{ row.applyTime || row.createTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå·¥ç¼å·">{{ row.employeeNo || row.applicantNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå·¥å§å">{{ row.employeeName || row.applicantName || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¥éåå " :span="2">{{ row.reimburseReason || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¥ééé¢">{{ row.applyAmount != null ? `${row.applyAmount} å
` : "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¶æ¬¾äºº">{{ row.payee || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¶æ¬¾è´¦å·">{{ row.payeeAccount || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="弿·æ¯è¡">{{ row.bankBranch || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item v-if="row.rejectReason" label="驳ååå " :span="2"> |
| | | <span class="reject-text">{{ row.rejectReason }}</span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="å建æ¶é´" :span="2">{{ row.createTime || "â" }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <el-divider content-position="left">æ¥éæç»</el-divider> |
| | | <el-table v-if="row.expenseDetails?.length" :data="row.expenseDetails" border size="small"> |
| | | <el-table-column type="index" label="åºå·" width="55" align="center" /> |
| | | <el-table-column prop="invoiceDate" label="åç¥¨æ¥æ" width="120" /> |
| | | <el-table-column label="è´¹ç¨ç§ç®" width="100"> |
| | | <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="amount" label="éé¢" width="100" /> |
| | | <el-table-column prop="description" label="æè¿°" min-width="140" show-overflow-tooltip /> |
| | | </el-table> |
| | | <el-empty v-else description="ææ æç»" :image-size="48" /> |
| | | |
| | | <el-divider content-position="left">å票éä»¶</el-divider> |
| | | <template v-if="attachmentFiles.length"> |
| | | <el-tag v-for="(f, i) in attachmentFiles" :key="i" class="file-tag" type="info" @click="openFile(f)"> |
| | | {{ f.name }} |
| | | </el-tag> |
| | | </template> |
| | | <el-empty v-else description="ææ éä»¶" :image-size="48" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | import { expenseCategoryLabel, expenseSubjectLabel, statusLabel, statusTagType } from "../costReimburseUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | row: { type: Object, default: () => ({}) }, |
| | | }); |
| | | |
| | | const attachmentFiles = computed(() => { |
| | | const list = props.row?.attachmentList?.length ? props.row.attachmentList : props.row?.invoiceAttachments; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | function openFile(f) { |
| | | const url = f?.url || f?.downloadURL || f?.previewURL; |
| | | if (url) window.open(url, "_blank"); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .reject-text { |
| | | color: var(--el-color-danger); |
| | | } |
| | | .file-tag { |
| | | margin: 0 8px 8px 0; |
| | | cursor: pointer; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | |
| | | /** è´¹ç¨æ¥é大类 */ |
| | | export const EXPENSE_CATEGORY_OPTIONS = [ |
| | | { label: "å·®æ
", value: "travel" }, |
| | | { label: "åå
¬éè´", value: "office_procurement" }, |
| | | { label: "ä¸å¡æå¾
", value: "business_entertainment" }, |
| | | { label: "交éè´¹", value: "transport" }, |
| | | { label: "é讯费", value: "communication" }, |
| | | { label: "å
¶ä»", value: "other" }, |
| | | ]; |
| | | |
| | | /** æç»è´¹ç¨ç§ç® */ |
| | | export const EXPENSE_SUBJECT_OPTIONS = [ |
| | | { label: "交éè´¹", value: "transport" }, |
| | | { label: "ä½å®¿è´¹", value: "hotel" }, |
| | | { label: "é¤é¥®è´¹", value: "meal" }, |
| | | { label: "åå
¬ç¨å", value: "office_supply" }, |
| | | { label: "æå¾
è´¹", value: "entertainment" }, |
| | | { label: "é讯费", value: "phone" }, |
| | | { label: "å
¶ä»", value: "other" }, |
| | | ]; |
| | | |
| | | /** åç±»å¡«æ¥æ¨¡æ¿ï¼ä¸é®è°ç¨ï¼ */ |
| | | export const CATEGORY_TEMPLATES = { |
| | | travel: { |
| | | label: "å·®æ
è´¹ç¨", |
| | | reason: "å å
¬åºå·®äº§çç交éãä½å®¿ãé¤é¥®çè´¹ç¨æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "transport", description: "å¾è¿äº¤éè´¹" }, |
| | | { expenseSubject: "hotel", description: "ä½å®¿è´¹" }, |
| | | { expenseSubject: "meal", description: "åºå·®é¤é¥®" }, |
| | | ], |
| | | }, |
| | | office_procurement: { |
| | | label: "åå
¬éè´", |
| | | reason: "é¨é¨æ¥å¸¸åå
¬ç¨åãèæéè´æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "office_supply", description: "åå
¬ç¨åéè´" }, |
| | | { expenseSubject: "office_supply", description: "æå°èæ" }, |
| | | ], |
| | | }, |
| | | business_entertainment: { |
| | | label: "ä¸å¡æå¾
", |
| | | reason: "å®¢æ·æ¥å¾
ãåå¡å®´è¯·çè´¹ç¨æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "entertainment", description: "å®¢æ·æ¥å¾
é¤è´¹" }, |
| | | { expenseSubject: "entertainment", description: "åå¡ç¤¼å" }, |
| | | ], |
| | | }, |
| | | transport: { |
| | | label: "交éè´¹", |
| | | reason: "å¸å
éå¤ãæè½¦ãå车ç交éè´¹ç¨æ¥éã", |
| | | details: [{ expenseSubject: "transport", description: "å¸å
交é" }], |
| | | }, |
| | | communication: { |
| | | label: "é讯费", |
| | | reason: "å å
¬éè®¯ãæµéãè¯è´¹è¡¥è´´æ¥éã", |
| | | details: [{ expenseSubject: "phone", description: "è¯è´¹/æµé" }], |
| | | }, |
| | | other: { |
| | | label: "å
¶ä»è´¹ç¨", |
| | | reason: "å
¶ä»å å
¬æ¯åºè´¹ç¨æ¥éã", |
| | | details: [{ expenseSubject: "other", description: "å
¶ä»è´¹ç¨" }], |
| | | }, |
| | | }; |
| | | |
| | | /** 审æ¹è§è²ä¸æ¨¡æå®¡æ¹äºº */ |
| | | export const MOCK_APPROVERS_BY_ROLE = { |
| | | direct_supervisor: { approverId: "mock_supervisor", approverName: "ç´å±ä¸çº§" }, |
| | | dept_manager: { approverId: "mock_manager", approverName: "é¨é¨ç»ç" }, |
| | | cfo: { approverId: "mock_cfo", approverName: "è´¢å¡æ»ç" }, |
| | | compliance: { approverId: "mock_compliance", approverName: "åè§å®¡æ ¸" }, |
| | | }; |
| | | |
| | | /** æéé¢é¢è®¾å®¡æ¹é¾ */ |
| | | export const APPROVAL_AMOUNT_RULES = [ |
| | | { |
| | | maxAmount: 500, |
| | | description: "500å
以å
ï¼ç´å±ä¸çº§å®¡æ¹", |
| | | roles: ["direct_supervisor"], |
| | | }, |
| | | { |
| | | maxAmount: 5000, |
| | | description: "500ï½5000å
ï¼ç´å±ä¸çº§ + é¨é¨ç»ç", |
| | | roles: ["direct_supervisor", "dept_manager"], |
| | | }, |
| | | { |
| | | maxAmount: Infinity, |
| | | description: "è¶
5000å
ï¼ç´å±ä¸çº§ + é¨é¨ç»ç + è´¢å¡æ»ç夿 ¸", |
| | | roles: ["direct_supervisor", "dept_manager", "cfo"], |
| | | }, |
| | | ]; |
| | | |
| | | /** é¨ååç±»é¢å¤å®¡æ¹èç¹ */ |
| | | export const CATEGORY_EXTRA_APPROVAL = { |
| | | business_entertainment: ["compliance"], |
| | | office_procurement: [], |
| | | }; |
| | | |
| | | export function expenseCategoryLabel(v) { |
| | | return EXPENSE_CATEGORY_OPTIONS.find((x) => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | 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 formatApprovalFlowSummary(row) { |
| | | const nodes = row?.approvalFlowNodes || []; |
| | | if (!nodes.length) return "â"; |
| | | return nodes |
| | | .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 resolveApprovalRoles(amount, expenseCategory) { |
| | | const amt = Number(amount) || 0; |
| | | let roles = []; |
| | | for (const rule of APPROVAL_AMOUNT_RULES) { |
| | | if (amt <= rule.maxAmount) { |
| | | roles = [...rule.roles]; |
| | | break; |
| | | } |
| | | } |
| | | if (!roles.length) roles = ["direct_supervisor"]; |
| | | const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || []; |
| | | extra.forEach((r) => { |
| | | if (!roles.includes(r)) roles.push(r); |
| | | }); |
| | | return roles; |
| | | } |
| | | |
| | | export function buildAutoApprovalFlow(amount, expenseCategory) { |
| | | const roles = resolveApprovalRoles(amount, expenseCategory); |
| | | return roles.map((role, i) => { |
| | | const mock = MOCK_APPROVERS_BY_ROLE[role] || { approverId: `mock_${role}`, approverName: role }; |
| | | return { |
| | | approverId: mock.approverId, |
| | | approverName: mock.approverName, |
| | | roleKey: role, |
| | | sortOrder: i + 1, |
| | | nodeOrder: i + 1, |
| | | nodeStatus: i === 0 ? "process" : "wait", |
| | | approveOpinion: "", |
| | | approveTime: "", |
| | | }; |
| | | }); |
| | | } |
| | | |
| | | export function getApprovalRuleHint(amount, expenseCategory) { |
| | | const amt = Number(amount) || 0; |
| | | const rule = APPROVAL_AMOUNT_RULES.find((r) => amt <= r.maxAmount) || APPROVAL_AMOUNT_RULES[APPROVAL_AMOUNT_RULES.length - 1]; |
| | | const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || []; |
| | | const extraText = extra.length |
| | | ? `ï¼${expenseCategoryLabel(expenseCategory)}ç±»å¦éï¼${extra.map((r) => MOCK_APPROVERS_BY_ROLE[r]?.approverName || r).join("ã")}` |
| | | : ""; |
| | | return `${rule.description}${extraText}`; |
| | | } |
| | | |
| | | 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: "", |
| | | expenseCategory: "", |
| | | reimburseReason: "", |
| | | applyAmount: undefined, |
| | | payee: "", |
| | | payeeAccount: "", |
| | | bankBranch: "", |
| | | expenseDetails: [], |
| | | attachmentList: [], |
| | | approvalFlowNodes: [], |
| | | currentNodeIndex: 0, |
| | | approvalResult: "pending", |
| | | rejectReason: "", |
| | | deptId: "", |
| | | deptName: "", |
| | | }; |
| | | } |
| | | |
| | | export function applyCategoryTemplate(form, category) { |
| | | const tpl = CATEGORY_TEMPLATES[category]; |
| | | if (!tpl) return; |
| | | form.expenseCategory = category; |
| | | if (!form.reimburseReason?.trim()) form.reimburseReason = tpl.reason; |
| | | form.expenseDetails = (tpl.details || []).map((d) => ({ |
| | | ...createEmptyExpenseDetail(), |
| | | expenseSubject: d.expenseSubject, |
| | | description: d.description, |
| | | invoiceDate: dayjs().format("YYYY-MM-DD"), |
| | | })); |
| | | } |
| | | |
| | | 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", rejectReason: "" }; |
| | | } |
| | | nodes[next] = { ...nodes[next], nodeStatus: "process" }; |
| | | return { nodes, currentNodeIndex: next, approvalResult: "pending", rejectReason: "" }; |
| | | } |
| | | |
| | | export function rejectApprovalFlow(row, opinion) { |
| | | const nodes = [...(row.approvalFlowNodes || [])]; |
| | | const idx = row.currentNodeIndex ?? 0; |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | const reason = (opinion || "").trim() || "驳å"; |
| | | if (nodes[idx]) { |
| | | nodes[idx] = { |
| | | ...nodes[idx], |
| | | nodeStatus: "error", |
| | | approveOpinion: reason, |
| | | approveTime: now, |
| | | }; |
| | | } |
| | | return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: reason }; |
| | | } |
| | | |
| | | export function normalizeImportedRow(raw, idx) { |
| | | const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`; |
| | | const expenseDetails = Array.isArray(raw.expenseDetails) ? raw.expenseDetails : []; |
| | | const applyAmount = raw.applyAmount ?? expenseDetails.reduce((s, d) => s + (Number(d.amount) || 0), 0); |
| | | const expenseCategory = raw.expenseCategory || "other"; |
| | | const approvalFlowNodes = |
| | | Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length |
| | | ? raw.approvalFlowNodes |
| | | : buildAutoApprovalFlow(applyAmount, expenseCategory); |
| | | |
| | | return { |
| | | id, |
| | | reimburseNo: raw.reimburseNo || `CR${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 ?? "æªç¥", |
| | | expenseCategory, |
| | | reimburseReason: raw.reimburseReason ?? "", |
| | | applyAmount, |
| | | payee: raw.payee ?? "", |
| | | payeeAccount: raw.payeeAccount ?? "", |
| | | bankBranch: raw.bankBranch ?? "", |
| | | expenseDetails, |
| | | attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [], |
| | | invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [], |
| | | 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 : [], |
| | | applyTime: raw.applyTime || raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | deptId: raw.deptId ?? "", |
| | | deptName: raw.deptName ?? "", |
| | | }; |
| | | } |
| | |
| | | <!-- |
| | | 模å䏿åï¼è´¹ç¨æ¥é |
| | | ç®å½æ è¯ï¼ReimburseManage/cost-reimburseï¼cost-reimburse â 䏿ï¼è´¹ç¨æ¥éï¼ |
| | | å¤ç¨é¡µé¢ï¼@/views/procurementManagement/procurementLedger/index.vueï¼éè´å°è´¦ï¼æä»¶å index.vue â å
¥å£é¡µï¼ |
| | | --> |
| | | <!--OA模åï¼è´¹ç¨æ¥é--> |
| | | <template> |
| | | <ProcurementLedger /> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div class="search_fields"> |
| | | <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.applyTimeFrom" |
| | | 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.applyTimeTo" |
| | | type="date" |
| | | placeholder="ç»ææ¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 150px; margin-left: 8px" |
| | | 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="cost-reimburse-form-dialog" |
| | | @closed="onFormClosed" |
| | | > |
| | | <el-alert type="info" show-icon :closable="false" class="mb16"> |
| | | <template #title>å
¨åç±»è´¹ç¨æ¥é · å类模æ¿ä¸é®å¡«æ¥</template> |
| | | <template #default> |
| | | æ¯æå·®æ
ãåå
¬éè´ãä¸å¡æå¾
ã交éè´¹ãé讯费çï¼æéé¢èªå¨å¹é
审æ¹é¾ï¼500å
å
ç´å±ä¸çº§ï¼è¶
5000å
è´¢å¡æ»ç夿 ¸ï¼ã |
| | | </template> |
| | | </el-alert> |
| | | |
| | | <div v-if="!formDialog.readonly" class="template-bar mb16"> |
| | | <span class="template-label">å类模æ¿ï¼</span> |
| | | <el-button |
| | | v-for="(tpl, key) in CATEGORY_TEMPLATES" |
| | | :key="key" |
| | | size="small" |
| | | :type="form.expenseCategory === key ? 'primary' : 'default'" |
| | | plain |
| | | @click="applyTemplate(key)" |
| | | > |
| | | {{ tpl.label }} |
| | | </el-button> |
| | | </div> |
| | | |
| | | <el-form |
| | | ref="formRef" |
| | | :model="form" |
| | | :rules="formRules" |
| | | label-width="120px" |
| | | class="cost-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="12"> |
| | | <el-form-item label="è´¹ç¨ç±»å" prop="expenseCategory"> |
| | | <el-select |
| | | v-model="form.expenseCategory" |
| | | placeholder="è¯·éæ©è´¹ç¨ç±»å" |
| | | style="width: 100%" |
| | | @change="onExpenseCategoryChange" |
| | | > |
| | | <el-option |
| | | v-for="opt in EXPENSE_CATEGORY_OPTIONS" |
| | | :key="opt.value" |
| | | :label="opt.label" |
| | | :value="opt.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æ¥éç¶æ"> |
| | | <el-tag |
| | | :type="form.approvalResult === 'approved' ? 'success' : form.approvalResult === 'rejected' ? 'danger' : 'warning'" |
| | | effect="plain" |
| | | > |
| | | {{ |
| | | form.approvalResult === "approved" |
| | | ? "å·²éè¿" |
| | | : form.approvalResult === "rejected" |
| | | ? "已驳å" |
| | | : "å®¡æ ¸ä¸" |
| | | }} |
| | | </el-tag> |
| | | </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="applyAmount"> |
| | | <div class="amount-row"> |
| | | <el-input-number |
| | | v-model="form.applyAmount" |
| | | :min="0" |
| | | :precision="2" |
| | | controls-position="right" |
| | | class="amount-input" |
| | | @change="autoAssignApprovalFlow" |
| | | /> |
| | | <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails"> |
| | | ææç»æ±æ» {{ detailTotalAmount }} å
|
| | | </el-button> |
| | | </div> |
| | | </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%"> |
| | | <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-row :gutter="20"> |
| | | <el-col :span="8"> |
| | | <el-form-item label="æ¶æ¬¾äºº" prop="payee"> |
| | | <el-input v-model="form.payee" placeholder="请è¾å
¥æ¶æ¬¾äºº" maxlength="50" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="æ¶æ¬¾è´¦å·" prop="payeeAccount"> |
| | | <el-input v-model="form.payeeAccount" placeholder="é¶è¡å¡å·" maxlength="30" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="弿·æ¯è¡" prop="bankBranch"> |
| | | <el-input v-model="form.bankBranch" placeholder="弿·æ¯è¡å
¨ç§°" maxlength="100" /> |
| | | </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-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> |
| | | <div class="card-header-row"> |
| | | <span class="card-header-title">å®¡æ¹æµç¨</span> |
| | | <el-button v-if="!formDialog.readonly" type="primary" link size="small" @click="autoAssignApprovalFlow"> |
| | | æè§åéæ°åé
|
| | | </el-button> |
| | | </div> |
| | | </template> |
| | | <el-alert type="success" :title="approvalRuleHint" show-icon :closable="false" class="mb12" /> |
| | | <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" /> |
| | | <el-divider content-position="left">å®¡æ¹æµç¨</el-divider> |
| | | <ApprovalFlowProgress :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="å®¡æ¹æè§" required> |
| | | <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 "../travel-reimburse/components/ApprovalFlowProgress.vue"; |
| | | import DetailPanel from "./components/DetailPanel.vue"; |
| | | import { useCostReimburse } from "./useCostReimburse.js"; |
| | | |
| | | const cr = useCostReimburse(); |
| | | const { |
| | | Search, |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | CATEGORY_TEMPLATES, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | expenseSubjectLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | importInputRef, |
| | | formRef, |
| | | form, |
| | | formDialog, |
| | | formRules, |
| | | detailDialog, |
| | | 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, |
| | | approvalActionLabel, |
| | | submitApprove, |
| | | handleExport, |
| | | handleImportClick, |
| | | onImportFile, |
| | | } = cr; |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .mb16 { |
| | | margin-bottom: 16px; |
| | | } |
| | | .mb12 { |
| | | margin-bottom: 12px; |
| | | } |
| | | .mt16 { |
| | | margin-top: 16px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_fields { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | .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; |
| | | } |
| | | .template-bar { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | .template-label { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-secondary); |
| | | flex-shrink: 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; |
| | | } |
| | | .attachment-form-item { |
| | | margin-bottom: 0; |
| | | } |
| | | .detail-table { |
| | | margin-bottom: 0; |
| | | } |
| | | .upload-block { |
| | | width: 100%; |
| | | } |
| | | .flow-tip { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | margin-top: 8px; |
| | | } |
| | | .cost-reimburse-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | .cost-reimburse-form :deep(.el-form-item) { |
| | | margin-bottom: 18px; |
| | | } |
| | | .cost-reimburse-form :deep(.el-input-number) { |
| | | width: 100%; |
| | | } |
| | | .cost-reimburse-form :deep(.el-row) { |
| | | margin-bottom: 0; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; |
| | | 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([ |
| | | { |
| | | id: "1", |
| | | reimburseNo: "CR202605100001", |
| | | applicantId: "mock_1", |
| | | employeeNo: "zhangsan", |
| | | employeeName: "å¼ ä¸", |
| | | applicantNo: "zhangsan", |
| | | applicantName: "å¼ ä¸", |
| | | expenseCategory: "office_procurement", |
| | | reimburseReason: "éè´æå°æºç¡é¼ãA4纸çåå
¬èæã", |
| | | applyAmount: 680, |
| | | payee: "å¼ ä¸", |
| | | payeeAccount: "6222 **** **** 1234", |
| | | bankBranch: "ä¸å½å·¥åé¶è¡æå·è¥¿æ¹æ¯è¡", |
| | | expenseDetails: [ |
| | | { id: "d1", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 380, description: "A4å¤å°çº¸" }, |
| | | { id: "d2", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 300, description: "ç¡é¼" }, |
| | | ], |
| | | attachmentList: [{ name: "éè´å票.pdf", url: "/mock/invoice1.pdf" }], |
| | | approvalFlowNodes: demoFlowNodes(680, "office_procurement"), |
| | | currentNodeIndex: 0, |
| | | approvalResult: "pending", |
| | | rejectReason: "", |
| | | approvalRecords: [], |
| | | applyTime: "2026-05-10 09:15:00", |
| | | createTime: "2026-05-10 09:15:00", |
| | | deptId: "101", |
| | | deptName: "è¡æ¿é¨", |
| | | }, |
| | | { |
| | | id: "2", |
| | | reimburseNo: "CR202605080002", |
| | | applicantId: "mock_2", |
| | | employeeNo: "lisi", |
| | | employeeName: "æå", |
| | | applicantNo: "lisi", |
| | | applicantName: "æå", |
| | | expenseCategory: "business_entertainment", |
| | | reimburseReason: "æ¥å¾
éç¹å®¢æ·åå¡å®´è¯·ã", |
| | | applyAmount: 3200, |
| | | payee: "æå", |
| | | payeeAccount: "6217 **** **** 5678", |
| | | bankBranch: "æåé¶è¡æ¦æ±å
è°·æ¯è¡", |
| | | expenseDetails: [ |
| | | { id: "d3", invoiceDate: "2026-05-06", expenseSubject: "entertainment", amount: 3200, description: "客æ·å®´è¯·" }, |
| | | ], |
| | | attachmentList: [], |
| | | approvalFlowNodes: demoFlowNodes(3200, "business_entertainment").map((n, i) => ({ |
| | | ...n, |
| | | nodeStatus: i === 0 ? "error" : "wait", |
| | | approveOpinion: i === 0 ? "å票模ç³ééä¼ " : "", |
| | | approveTime: i === 0 ? "2026-05-09 14:20:00" : "", |
| | | })), |
| | | currentNodeIndex: 0, |
| | | approvalResult: "rejected", |
| | | rejectReason: "å票模ç³ééä¼ ", |
| | | approvalRecords: [ |
| | | { operatorName: "ç´å±ä¸çº§", result: "rejected", opinion: "å票模ç³ééä¼ ", time: "2026-05-09 14:20:00" }, |
| | | ], |
| | | applyTime: "2026-05-07 16:30:00", |
| | | createTime: "2026-05-07 16:30:00", |
| | | deptId: "102", |
| | | deptName: "éå®é¨", |
| | | }, |
| | | { |
| | | id: "3", |
| | | reimburseNo: "CR202605050003", |
| | | applicantId: "mock_3", |
| | | employeeNo: "wangwu", |
| | | employeeName: "çäº", |
| | | applicantNo: "wangwu", |
| | | applicantName: "çäº", |
| | | expenseCategory: "communication", |
| | | reimburseReason: "5æå å
¬è¯è´¹æ¥éã", |
| | | applyAmount: 198, |
| | | payee: "çäº", |
| | | payeeAccount: "6228 **** **** 9012", |
| | | bankBranch: "ä¸å½å»ºè®¾é¶è¡æé½é«æ°æ¯è¡", |
| | | expenseDetails: [ |
| | | { id: "d4", invoiceDate: "2026-05-05", expenseSubject: "phone", amount: 198, description: "è¯è´¹è´¦å" }, |
| | | ], |
| | | attachmentList: [{ name: "è¯è´¹è´¦å.jpg", url: "/mock/phone.jpg" }], |
| | | approvalFlowNodes: demoFlowNodes(198, "communication").map((n) => ({ |
| | | ...n, |
| | | nodeStatus: "finish", |
| | | approveOpinion: "åæ", |
| | | approveTime: "2026-05-06 10:00:00", |
| | | })), |
| | | currentNodeIndex: 0, |
| | | approvalResult: "approved", |
| | | rejectReason: "", |
| | | approvalRecords: [{ operatorName: "ç´å±ä¸çº§", result: "approved", opinion: "åæ", time: "2026-05-06 10:00:00" }], |
| | | applyTime: "2026-05-05 11:00:00", |
| | | createTime: "2026-05-05 11:00:00", |
| | | deptId: "103", |
| | | deptName: "ææ¯é¨", |
| | | }, |
| | | ]); |
| | | |
| | | const searchForm = reactive({ |
| | | applicantKeyword: "", |
| | | applyTimeFrom: "", |
| | | applyTimeTo: "", |
| | | }); |
| | | 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 detailRow = ref({}); |
| | | const approveDialog = reactive({ visible: false, row: null }); |
| | | const approveOpinion = ref(""); |
| | | |
| | | 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) => ({ |
| | | ...r, |
| | | approvalFlowSummary: formatApprovalFlowSummary(r), |
| | | })); |
| | | }); |
| | | |
| | | 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) => row.approvalResult === "pending" || row.approvalResult === "approved", |
| | | clickFun: (row) => openFormDialog("edit", row), |
| | | }, |
| | | { name: "详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | { |
| | | name: "审æ¹", |
| | | type: "text", |
| | | disabled: (row) => row.approvalResult !== "pending", |
| | | clickFun: (row) => openApprove(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"); |
| | | 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; |
| | | tableLoading.value = true; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 150); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.applicantKeyword = ""; |
| | | searchForm.applyTimeFrom = ""; |
| | | searchForm.applyTimeTo = ""; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination(obj) { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | 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) { |
| | | 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 || [])), |
| | | }); |
| | | const u = userById(row.applicantId); |
| | | applicantFormOptions.value = u |
| | | ? [u] |
| | | : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.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(); |
| | | 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?.("ä¿åæåï¼æ¬å°æ¨¡æï¼"); |
| | | } |
| | | formDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | 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 = filteredList.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?.("è§£æå¤±è´¥"); |
| | | } |
| | | }; |
| | | reader.readAsText(file, "utf-8"); |
| | | } |
| | | |
| | | onMounted(() => loadUserPool()); |
| | | |
| | | return { |
| | | Search, |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | CATEGORY_TEMPLATES, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | expenseCategoryLabel, |
| | | expenseSubjectLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | importInputRef, |
| | | formRef, |
| | | form, |
| | | formDialog, |
| | | formRules, |
| | | detailDialog, |
| | | 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, |
| | | openDetail, |
| | | approvalActionLabel, |
| | | submitApprove, |
| | | handleExport, |
| | | handleImportClick, |
| | | onImportFile, |
| | | }; |
| | | } |