index.vue 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582
  1. <template>
  2. <div class="ele-body regularization-page" v-loading="loading">
  3. <el-card shadow="never">
  4. <el-form label-width="100px" class="ele-form-search" @keyup.enter.native="applyFilters" @submit.native.prevent>
  5. <el-row :gutter="15">
  6. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  7. <el-form-item label="姓名" class="name-item">
  8. <el-select v-model="draftFilters.userId" filterable remote clearable reserve-keyword
  9. :remote-method="searchEmployees" :loading="employeeLoading" placeholder="输入姓名 / 工号搜索"
  10. style="width: 100%">
  11. <el-option v-for="item in employeeOptions" :key="String(item.id)"
  12. :label="`${item.name}(${item.employeeNo || '-'})`" :value="item.id" />
  13. </el-select>
  14. </el-form-item>
  15. </el-col>
  16. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  17. <el-form-item label="所属机构">
  18. <el-select v-model="draftFilters.organizationId" clearable filterable placeholder="请选择"
  19. style="width: 100%">
  20. <el-option v-for="item in legalEntities" :key="String(item.id)" :label="item.name" :value="item.id" />
  21. </el-select>
  22. </el-form-item>
  23. </el-col>
  24. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  25. <el-form-item label="转正类型">
  26. <el-select v-model="draftFilters.regularizationType" clearable placeholder="请选择" style="width: 100%">
  27. <el-option v-for="(label, value) in typeOptions" :key="value" :label="label" :value="value" />
  28. </el-select>
  29. </el-form-item>
  30. </el-col>
  31. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  32. <el-form-item label="计划转正日期">
  33. <el-date-picker v-model="draftFilters.dateRange" type="daterange" value-format="yyyy-MM-dd"
  34. range-separator="至" start-placeholder="请选择" end-placeholder="请选择" style="width: 100%" />
  35. </el-form-item>
  36. </el-col>
  37. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  38. <el-form-item label="办理状态" class="status-item">
  39. <el-select v-model="activeStatus" placeholder="请选择" style="width: 100%" @change="reloadTable">
  40. <el-option v-for="tab in tabs" :key="tab.value" :label="tab.label" :value="tab.value" />
  41. </el-select>
  42. </el-form-item>
  43. </el-col>
  44. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  45. <el-form-item label="实际生效日期">
  46. <el-date-picker v-model="draftFilters.effectiveRange" type="daterange" value-format="yyyy-MM-dd"
  47. range-separator="至" start-placeholder="请选择" end-placeholder="请选择" style="width: 100%" />
  48. </el-form-item>
  49. </el-col>
  50. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  51. <div class="ele-form-actions">
  52. <el-button type="primary" icon="el-icon-search" class="ele-btn-icon" @click="applyFilters">
  53. 查询
  54. </el-button>
  55. <el-button type="primary" icon="el-icon-refresh" class="ele-btn-icon" @click="resetFilters">
  56. 重置
  57. </el-button>
  58. </div>
  59. </el-col>
  60. </el-row>
  61. </el-form>
  62. <ele-pro-table ref="table" row-key="id" :columns="columns" :datasource="fetchPage" :need-page="true"
  63. :page-size="20" :cache-key="$tableCacheKey(cacheKey)" class="regularization-table" @selection-change="selectedRows = $event">
  64. <template v-slot:toolbar>
  65. <el-button v-if="$hasPermission('main:hrRegularization:save')" type="primary" icon="el-icon-plus"
  66. class="ele-btn-icon" @click="openCreate()">
  67. 新建
  68. </el-button>
  69. <el-button v-if="$hasPermission('main:hrRegularization:export')" icon="el-icon-download"
  70. class="ele-btn-icon" @click="exportRows">
  71. 导出
  72. </el-button>
  73. </template>
  74. <!-- 点员工姓名开详情,替代原操作列「查看」入口 -->
  75. <template v-slot:name="{ row }">
  76. <el-link v-if="row.name" type="primary" :underline="false" @click.stop="openDetail(row)">
  77. {{ row.name }}
  78. </el-link>
  79. <span v-else>-</span>
  80. </template>
  81. <template v-slot:position="{ row }">
  82. {{ row.position || "-" }}
  83. </template>
  84. <template v-slot:date="{ row }">
  85. {{ row.effectiveDate || row.regularDate || row.plannedDate || "-" }}
  86. </template>
  87. <template v-slot:status="{ row }">
  88. <el-tag v-if="row.status" size="mini" :type="statusTagType(row.status)">
  89. {{ row.statusLabel || statusLabel(row.status) }}
  90. </el-tag>
  91. <span v-else>-</span>
  92. </template>
  93. <template v-slot:approval="{ row }">
  94. <el-tag size="mini" :type="approvalType(row.approvalStatus)">
  95. {{ row.approvalStatusLabel || row.approvalStatus || "未提交" }}
  96. </el-tag>
  97. </template>
  98. <template v-slot:action="{ row }">
  99. <el-link v-if="canEdit(row) && $hasPermission('main:hrRegularization:update')" type="primary"
  100. :underline="false" icon="el-icon-edit" @click.stop="openCreate(row)">
  101. 编辑
  102. </el-link>
  103. <el-link v-if="canSubmit(row) && $hasPermission('main:hrRegularization:submit')" type="primary"
  104. :underline="false" icon="el-icon-s-promotion" @click.stop="launchApproval(row)">
  105. 提交
  106. </el-link>
  107. <el-link v-if="canViewApproval(row)" type="primary" :underline="false" icon="el-icon-connection"
  108. @click.stop="openApprovalRecord(row)">
  109. 审批记录
  110. </el-link>
  111. <el-link v-if="canCancel(row) && $hasPermission('main:hrRegularization:cancel')" type="danger"
  112. :underline="false" icon="el-icon-close" @click.stop="cancelRow(row)">
  113. 取消
  114. </el-link>
  115. </template>
  116. <template slot="empty">
  117. <el-empty description="暂无试用期员工或转正单据" />
  118. </template>
  119. </ele-pro-table>
  120. </el-card>
  121. <el-drawer :visible.sync="drawerVisible" direction="rtl" :size="drawerSize" append-to-body
  122. custom-class="regularization-drawer" :wrapper-closable="false">
  123. <template slot="title">
  124. <div class="drawer-title">
  125. <span>
  126. <i :class="readOnly ? 'el-icon-document' : 'el-icon-edit-outline'"></i>
  127. </span>
  128. <div>
  129. <h2>
  130. {{ readOnly ? "转正申请详情" : "发起转正申请" }}
  131. <!-- 状态文案一律用接口返回的中文名(生效状态优先,其次审批状态),本地不再翻译 -->
  132. <el-tag v-if="form.effectiveStatusLabel || form.approvalStatusLabel" size="mini"
  133. :type="statusTagType(form.status)" style="margin-left:8px">
  134. {{ form.effectiveStatusLabel || form.approvalStatusLabel }}
  135. </el-tag>
  136. </h2>
  137. <p>{{ form.employeeNo || "选择员工并完善申请资料" }}</p>
  138. </div>
  139. </div>
  140. </template>
  141. <div class="drawer-body" v-loading="drawerLoading">
  142. <el-form ref="applyForm" :model="form" :rules="rules" label-position="top" :disabled="readOnly"
  143. class="apply-form">
  144. <section class="form-section">
  145. <header>
  146. <span><i class="el-icon-user"></i></span>
  147. <div>
  148. <h3>员工信息</h3>
  149. <p>从试用期员工档案自动带出</p>
  150. </div>
  151. </header>
  152. <div class="form-grid">
  153. <el-form-item label="申请员工" prop="userId">
  154. <el-select v-model="form.userId" filterable placeholder="请选择试用期员工" :disabled="readOnly"
  155. @change="selectEmployee">
  156. <el-option v-for="item in selectableEmployees" :key="item.userId"
  157. :label="`${item.name}(${item.employeeNo || '-'})`" :value="item.userId" />
  158. </el-select>
  159. </el-form-item>
  160. <el-form-item label="手机号" prop="phone">
  161. <el-input v-model.trim="form.phone" maxlength="11" :disabled="readOnly" placeholder="请输入 11 位手机号码" />
  162. </el-form-item>
  163. <el-form-item label="所属机构" prop="groupId">
  164. <el-select v-model="form.groupId" filterable clearable :disabled="readOnly" placeholder="请选择所属机构"
  165. style="width: 100%" @change="onCascadeLegalChange">
  166. <el-option v-for="item in legalEntities" :key="item.id" :label="item.name" :value="item.id" />
  167. </el-select>
  168. </el-form-item>
  169. <el-form-item label="所属部门" prop="deptId">
  170. <el-select v-model="form.deptId" filterable clearable :disabled="readOnly || !form.groupId"
  171. :placeholder="form.groupId ? '请选择所属部门' : '请先选择所属机构'" style="width: 100%"
  172. @change="onCascadeDeptChange">
  173. <el-option v-for="item in deptOptions" :key="item.id" :label="item.name" :value="item.id" />
  174. </el-select>
  175. </el-form-item>
  176. <el-form-item label="岗位序列" prop="positionSequenceId">
  177. <el-select v-model="form.positionSequenceId" filterable clearable :loading="sequenceLoading"
  178. :disabled="readOnly || !form.deptId" :placeholder="form.deptId ? '请选择岗位序列' : '请先选择所属部门'"
  179. style="width: 100%" @change="onCascadeSequenceChange">
  180. <el-option v-for="item in sequenceOptions" :key="item.id" :label="item.name" :value="item.id" />
  181. </el-select>
  182. </el-form-item>
  183. <el-form-item label="岗位名称" prop="positionId">
  184. <el-select v-model="form.positionId" filterable clearable :loading="positionLoading"
  185. :disabled="readOnly || !form.positionSequenceId" :placeholder="form.positionSequenceId ? '请选择岗位名称' : '请先选择岗位序列'
  186. " style="width: 100%" @change="onCascadePositionChange">
  187. <el-option v-for="item in cascadePositionOptions" :key="item.id" :label="item.name"
  188. :value="item.id" />
  189. </el-select>
  190. </el-form-item>
  191. <el-form-item label="岗位性质" prop="positionType">
  192. <el-input v-model="form.positionTypeName" readonly placeholder="选择岗位名称后自动带出" />
  193. </el-form-item>
  194. <el-form-item label="岗位层级" prop="positionLevelId">
  195. <el-select v-model="form.positionLevelId" filterable clearable :loading="levelLoading"
  196. :disabled="readOnly || !form.positionId" :placeholder="form.positionId ? '请选择岗位层级' : '请先选择岗位名称'"
  197. style="width: 100%" @change="onCascadeLevelChange">
  198. <el-option v-for="item in levelOptions" :key="item.id" :label="item.name" :value="item.id" />
  199. </el-select>
  200. </el-form-item>
  201. <el-form-item label="直属上级">
  202. <el-select v-model="form.managerUserId" filterable remote clearable :remote-method="searchManagers"
  203. :loading="managerLoading" :disabled="readOnly" placeholder="输入姓名/工号搜索全员" style="width: 100%"
  204. @change="onManagerChange">
  205. <el-option v-for="item in managerOptions" :key="item.id"
  206. :label="`${item.name}(${item.employeeNo || '-'})`" :value="item.id" />
  207. </el-select>
  208. </el-form-item>
  209. </div>
  210. </section>
  211. <section class="form-section">
  212. <header>
  213. <span><i class="el-icon-date"></i></span>
  214. <div>
  215. <h3>转正信息</h3>
  216. <p>设置转正类型、生效日期与签署方式</p>
  217. </div>
  218. </header>
  219. <div class="form-grid">
  220. <el-form-item label="入职日期">
  221. <el-input v-model="form.hireDate" disabled />
  222. </el-form-item>
  223. <el-form-item label="转正类型" prop="regularizationType">
  224. <el-select v-model="form.regularizationType" placeholder="请选择转正类型">
  225. <el-option v-for="(label, value) in typeOptions" :key="value" :label="label" :value="value" />
  226. </el-select>
  227. </el-form-item>
  228. <el-form-item label="计划转正日期" prop="plannedRegularDate">
  229. <el-date-picker v-model="form.plannedRegularDate" type="date" value-format="yyyy-MM-dd" />
  230. </el-form-item>
  231. <el-form-item label="实际生效日期">
  232. <el-date-picker v-model="form.effectiveDate" type="date" value-format="yyyy-MM-dd"
  233. placeholder="为空则按计划日期生效" />
  234. </el-form-item>
  235. <el-form-item label="签署方式">
  236. <div class="sign-method-switch">
  237. <!-- 线上模板功能未开通:按钮弱化展示,点击只提示不切换 -->
  238. <button type="button" class="sign-method-btn is-unavailable" :class="{ 'is-active': isOnlineSign }"
  239. :disabled="readOnly" title="暂无开通模板功能,敬请期待"
  240. @click="!readOnly && setSigningMethod('ONLINE')">
  241. 线上签署
  242. </button>
  243. <button type="button" class="sign-method-btn" :class="{ 'is-active': !isOnlineSign }"
  244. :disabled="readOnly" @click="!readOnly && setSigningMethod('OFFLINE')">
  245. 线下签署
  246. </button>
  247. </div>
  248. </el-form-item>
  249. <!-- 转正申请模板 / 申请资料:二者只能出现一个,占签署方式右侧同一单元格 -->
  250. <el-form-item v-if="isOnlineSign" label="转正申请模板" prop="onlineDocumentTemplateId">
  251. <el-select v-model="form.onlineDocumentTemplateId" filterable clearable :loading="templateLoading"
  252. placeholder="请选择已启用线上模板" @visible-change="onTemplateDropdown">
  253. <el-option v-for="item in enabledTemplates" :key="item.id" :label="item.templateName || item.name"
  254. :value="item.id" />
  255. </el-select>
  256. </el-form-item>
  257. <el-form-item v-else label="申请资料" prop="offlineFileIds">
  258. <fileMain v-model="offlineDocIds" :type="readOnly ? 'view' : ''" size="small" />
  259. </el-form-item>
  260. </div>
  261. <template v-if="form.regularizationType === 'EXTENDED'">
  262. <div class="form-grid">
  263. <el-form-item label="延期天数" prop="extensionDays">
  264. <el-input-number v-model="form.extensionDays" :min="1" controls-position="right" />
  265. </el-form-item>
  266. <el-form-item label="延期后试用结束日" prop="newProbationEndDate">
  267. <el-date-picker v-model="form.newProbationEndDate" type="date" value-format="yyyy-MM-dd" />
  268. </el-form-item>
  269. </div>
  270. <el-form-item label="延期原因" prop="extensionReason">
  271. <el-input v-model="form.extensionReason" type="textarea" :rows="2" maxlength="200" show-word-limit />
  272. </el-form-item>
  273. </template>
  274. </section>
  275. </el-form>
  276. </div>
  277. <div class="drawer-footer">
  278. <span>
  279. <i class="el-icon-info"></i>
  280. M02 转正模块:提交审批走 BPM 公共流程
  281. </span>
  282. <div>
  283. <el-button :disabled="submitting" @click="drawerVisible = false">关闭</el-button>
  284. <el-button v-if="!readOnly" :loading="submitting" @click="saveDraft">保存</el-button>
  285. <el-button v-if="!readOnly && !isLockedForSubmit && $hasPermission('main:hrRegularization:submit')"
  286. type="primary" :loading="submitting" @click="submitApplication">提交审核</el-button>
  287. </div>
  288. </div>
  289. </el-drawer>
  290. <!-- BPM 公共审批发起弹框 -->
  291. <processSubmitDialog :processSubmitDialogFlag.sync="processSubmitDialogFlag" v-if="processSubmitDialogFlag"
  292. ref="processSubmitDialogRef" @reload="onProcessSubmitReload" />
  293. <!-- 审批流程详情弹框 -->
  294. <BpmProcessDetailDialog ref="approvalRecordDialog" />
  295. </div>
  296. </template>
  297. <script>
  298. import * as XLSX from "xlsx";
  299. import { showRequestError } from "@/utils/message";
  300. import { getCurrentUser } from "@/utils/token-util";
  301. import processSubmitDialog from "@/BIZComponents/processSubmitDialog/processSubmitDialog.vue";
  302. import BpmProcessDetailDialog from "@/views/bpm/processInstance/detailDialog.vue";
  303. import {
  304. adaptEmployee,
  305. fetchAllUsers,
  306. getEmployeeById,
  307. searchUsersByKeyword,
  308. } from "@/api/organization";
  309. /* 岗位性质字典与岗位档案同源:api/sys 的 getDictByCode 已兼容结构化项与「{ 1: "全职" }」老格式 */
  310. import { getDictByCode } from "@/api/sys";
  311. import {
  312. REGULARIZATION_TYPE_LABELS,
  313. DISPLAY_STATUS_LABELS,
  314. cancelRegularization,
  315. createRegularization,
  316. getEmploymentPeriods,
  317. getRegularizationById,
  318. getRegularizationPage,
  319. getRegularizationTemplatePage,
  320. mergeEmployeeRegularization,
  321. removeRegularizationBillId,
  322. resolveRegularizationForUser,
  323. resolveRegularizationIdFromHistory,
  324. saveRegularizationBillId,
  325. updateRegularization,
  326. } from "@/api/hr/regularization";
  327. import { REGULARIZATION_APPROVAL_BUSINESS_KEY } from "@/api/hr";
  328. import positionCascade from "@/views/hrManagement/mixins/positionCascade";
  329. /* 员工状态码统一取 hr_user_status 字典(不硬编码码值),见 resolveProbationStatusCode */
  330. import { loadEmployeeStatusOptions } from "@/utils/employee-status-dict";
  331. /* 所属机构默认值取登录缓存(loginChangeGroupVOList 第一条) */
  332. import { getCurrentOrgId } from "@/utils/current-org";
  333. /** 办理状态页签 → 接口查询条件(POST /main/regularizations/page)
  334. * Swagger 字段 handleStatus 枚举:DRAFT/APPROVING/APPROVED/REJECTED/WITHDRAWN/CANCELLED
  335. */
  336. const STATUS_TAB_QUERY = {
  337. draft: { handleStatus: "DRAFT" },
  338. reviewing: { handleStatus: "APPROVING" },
  339. done: { handleStatus: "APPROVED" },
  340. pending_effective: { handleStatus: "APPROVED" },
  341. rejected: { handleStatus: "REJECTED" },
  342. withdrawn: { handleStatus: "WITHDRAWN" },
  343. cancelled: { handleStatus: "CANCELLED" },
  344. };
  345. const emptyFilters = () => ({
  346. userId: null,
  347. /** 所属机构主键(后端 organizationId:筛选范围含该机构及全部下级部门),默认登录机构列表第一个 */
  348. organizationId: getCurrentOrgId() || null,
  349. regularizationType: "",
  350. dateRange: [],
  351. effectiveRange: [],
  352. });
  353. const emptyForm = () => ({
  354. userId: "",
  355. regularizationId: null,
  356. employeeNo: "",
  357. name: "",
  358. company: "",
  359. department: "",
  360. position: "",
  361. level: "",
  362. hireDate: "",
  363. plannedDate: "",
  364. plannedRegularDate: "",
  365. regularDate: "",
  366. effectiveDate: "",
  367. phone: "",
  368. manager: "",
  369. managerUserId: null,
  370. managerUserName: "",
  371. // 签署方式(接口字段 signingMethod):ONLINE 线上 / OFFLINE 线下
  372. signingMethod: "OFFLINE",
  373. online: false,
  374. onlineDocumentTemplateId: null,
  375. approvalDocumentFileId: null,
  376. offlineFileIds: [],
  377. regularizationType: "NORMAL",
  378. regularizationTypeLabel: REGULARIZATION_TYPE_LABELS.NORMAL,
  379. extensionDays: undefined,
  380. extensionReason: "",
  381. newProbationEndDate: "",
  382. employmentPeriodId: null,
  383. remark: "",
  384. status: "pending_apply",
  385. statusLabel: DISPLAY_STATUS_LABELS.pending_apply,
  386. approvalStatus: "",
  387. approvalStatusLabel: "未提交",
  388. approvalComment: "",
  389. approvalInstanceId: "",
  390. // 机构/部门/岗位主键(级联下拉用)
  391. groupId: null,
  392. deptId: null,
  393. positionId: null,
  394. // 岗位序列/层级/性质字段(提交给后端)
  395. positionSequenceId: "",
  396. positionSequence: "",
  397. positionLevelId: "",
  398. positionLevel: "",
  399. positionType: "",
  400. positionTypeName: "",
  401. });
  402. /** 机构 → 部门 → 岗位序列 → 岗位名称 → 岗位性质 → 岗位层级 级联字段映射 */
  403. const CASCADE_CONFIG = {
  404. formKey: "form",
  405. formRef: "applyForm",
  406. legalId: "groupId",
  407. legalName: "company",
  408. deptId: "deptId",
  409. deptName: "department",
  410. sequenceId: "positionSequenceId",
  411. sequenceName: "positionSequence",
  412. positionId: "positionId",
  413. positionName: "position",
  414. typeId: "positionType",
  415. typeName: "positionTypeName",
  416. levelId: "positionLevelId",
  417. levelName: "positionLevel",
  418. };
  419. export default {
  420. name: "RegularizationHandling",
  421. components: { processSubmitDialog, BpmProcessDetailDialog },
  422. mixins: [positionCascade],
  423. data() {
  424. return {
  425. loading: false,
  426. drawerLoading: false,
  427. submitting: false,
  428. templateLoading: false,
  429. rows: [],
  430. totalCount: 0,
  431. enabledTemplates: [],
  432. cacheKey: "hr-regularization-handling-v1",
  433. /** 查询表单(未提交) */
  434. draftFilters: emptyFilters(),
  435. /** 已生效的查询条件 */
  436. filters: emptyFilters(),
  437. activeStatus: "all",
  438. tabs: [
  439. { label: "全部", value: "all" },
  440. { label: "待申请", value: "pending_apply" },
  441. { label: "草稿", value: "draft" },
  442. { label: "审批中", value: "reviewing" },
  443. { label: "待生效", value: "pending_effective" },
  444. { label: "已转正", value: "done" },
  445. { label: "已驳回", value: "rejected" },
  446. { label: "已撤回", value: "withdrawn" },
  447. { label: "已取消", value: "cancelled" },
  448. ],
  449. /** 姓名筛选:远程搜索员工 */
  450. employeeOptions: [],
  451. employeeLoading: false,
  452. /** 直属上级:远程搜索全员 */
  453. managerOptions: [],
  454. managerLoading: false,
  455. /** 全量组织(含部门):仅「待申请」按机构上溯匹配员工部门用 */
  456. orgOptions: [],
  457. /** 试用期员工(转正申请可选人员 + 待申请列表) */
  458. trialEmployees: [],
  459. /** 已有转正单的员工集合(待申请列表用,按需加载) */
  460. billUserIds: null,
  461. /** 岗位性质字典(position_nature),用于新建/编辑转正申请时选择岗位性质 */
  462. positionNatureOptions: [],
  463. typeOptions: REGULARIZATION_TYPE_LABELS,
  464. selectedRows: [],
  465. drawerVisible: false,
  466. readOnly: false,
  467. form: emptyForm(),
  468. approvalDocIds: [],
  469. offlineDocIds: [],
  470. processSubmitDialogFlag: false,
  471. cascadeConfig: CASCADE_CONFIG,
  472. rules: {
  473. // 机构/部门/岗位序列/岗位名称/岗位性质/岗位层级/手机号 必填
  474. ...this.cascadeRequiredRules(CASCADE_CONFIG),
  475. userId: [{ required: true, message: "请选择员工", trigger: "change" }],
  476. regularizationType: [
  477. { required: true, message: "请选择转正类型", trigger: "change" },
  478. ],
  479. plannedRegularDate: [
  480. { required: true, message: "请选择计划转正日期", trigger: "change" },
  481. ],
  482. onlineDocumentTemplateId: [
  483. {
  484. validator: (rule, value, callback) => {
  485. if (this.isOnlineSign && !value) {
  486. callback(new Error("请选择启用模板"));
  487. } else {
  488. callback();
  489. }
  490. },
  491. trigger: "change",
  492. },
  493. ],
  494. extensionDays: [
  495. {
  496. validator: (rule, value, callback) => {
  497. if (
  498. this.form.regularizationType === "EXTENDED" &&
  499. !(Number(value) > 0)
  500. ) {
  501. callback(new Error("请填写延期天数"));
  502. } else {
  503. callback();
  504. }
  505. },
  506. trigger: "change",
  507. },
  508. ],
  509. extensionReason: [
  510. {
  511. validator: (rule, value, callback) => {
  512. if (
  513. this.form.regularizationType === "EXTENDED" &&
  514. !String(value || "").trim()
  515. ) {
  516. callback(new Error("请填写延期原因"));
  517. } else {
  518. callback();
  519. }
  520. },
  521. trigger: "blur",
  522. },
  523. ],
  524. },
  525. };
  526. },
  527. computed: {
  528. styleResponsive() {
  529. return this.$store.state.theme?.styleResponsive !== false;
  530. },
  531. drawerSize() {
  532. return window.innerWidth < 900 ? "96%" : "720px";
  533. },
  534. /**
  535. * 是否线上签署:以表单 signingMethod(详情接口值)为准,
  536. * 老数据没有该字段时回退到线上签署开关 online
  537. */
  538. isOnlineSign() {
  539. const method = String(this.form.signingMethod || "").toUpperCase();
  540. if (method === "ONLINE") return true;
  541. if (method === "OFFLINE") return false;
  542. return this.form.online === true;
  543. },
  544. /** 提交审批是否锁住(审批中/已转正/已取消时不可再提) */
  545. isLockedForSubmit() {
  546. if (!this.form.regularizationId) return false; // 新建中永远允许提交
  547. return [
  548. "reviewing",
  549. "done",
  550. "pending_effective",
  551. "cancelled",
  552. ].includes(this.form.status);
  553. },
  554. columns() {
  555. return [
  556. { type: "selection", width: 48, align: "center" },
  557. { type: "index", label: "序号", width: 60, align: "center" },
  558. {
  559. prop: "name",
  560. label: "员工",
  561. minWidth: 90,
  562. align: "left",
  563. showOverflowTooltip: true,
  564. slot: "name",
  565. },
  566. {
  567. /* 所属机构直取接口 groupName(2026-10-29 用户确认字段口径),不再走 slot 拼 company */
  568. prop: "groupName",
  569. label: "所属机构",
  570. minWidth: 240,
  571. align: "left",
  572. showOverflowTooltip: true,
  573. formatter: (_r, _c, value) => value || "-",
  574. },
  575. {
  576. /* 所属部门直取接口 deptName */
  577. prop: "deptName",
  578. label: "所属部门",
  579. minWidth: 140,
  580. align: "left",
  581. showOverflowTooltip: true,
  582. formatter: (_r, _c, value) => value || "-",
  583. },
  584. {
  585. prop: "position",
  586. label: "岗位信息",
  587. minWidth: 130,
  588. align: "left",
  589. showOverflowTooltip: true,
  590. slot: "position",
  591. },
  592. {
  593. prop: "hireDate",
  594. label: "入职日期",
  595. width: 100,
  596. align: "left",
  597. formatter: (_r, _c, value) => value || "-",
  598. },
  599. {
  600. prop: "plannedRegularDate",
  601. label: "转正日期",
  602. width: 100,
  603. align: "left",
  604. slot: "date",
  605. },
  606. {
  607. prop: "approvalStatus",
  608. label: "审核进度",
  609. minWidth: 90,
  610. align: "left",
  611. slot: "approval",
  612. },
  613. {
  614. prop: "createUserName",
  615. label: "创建人",
  616. width: 100,
  617. align: "left",
  618. formatter: (_r, _c, v) => v || "-",
  619. },
  620. {
  621. prop: "createTime",
  622. label: "创建时间",
  623. width: 100,
  624. align: "left",
  625. formatter: (_r, _c, v) => v || "-",
  626. },
  627. {
  628. prop: "updateUserName",
  629. label: "修改人",
  630. width: 100,
  631. align: "left",
  632. formatter: (_r, _c, v) => v || "-",
  633. },
  634. {
  635. prop: "updateTime",
  636. label: "修改时间",
  637. width: 100,
  638. align: "left",
  639. formatter: (_r, _c, v) => v || "-",
  640. },
  641. {
  642. columnKey: "action",
  643. label: "操作",
  644. minWidth: 220,
  645. slot: "action",
  646. fixed: "right",
  647. align: "left",
  648. },
  649. ];
  650. },
  651. /**
  652. * 转正申请的「申请员工」下拉:**尚未转正(没有转正单)的试用期员工**。
  653. * 数据源 trialEmployees 只按 状态=试用期/未离职 拉全量,本身不含「是否已有转正单」的排除,
  654. * 故这里用 billUserIds(「已有转正单」的员工集合,与「待申请」页签同一份)把已申请的人剔除。
  655. * ⚠ 编辑/查看已存在单据时,必须把当前申请人保留在选项里,否则 el-select 回显不出 label(会显示裸 id)。
  656. */
  657. selectableEmployees() {
  658. const employees = this.trialEmployees;
  659. const bill = this.billUserIds;
  660. const filtered = bill
  661. ? employees.filter((item) => !bill.has(String(item.userId)))
  662. : employees;
  663. const current = this.form?.userId;
  664. if (!current) return filtered;
  665. const key = String(current);
  666. if (filtered.some((item) => String(item.userId) === key)) return filtered;
  667. /* 回显:当前申请人已被差集剔除(或已不在试用期列表)时补回选项;
  668. 姓名/工号取单据自身字段,取不到就留空,不编造 */
  669. const inTrial = employees.find((item) => String(item.userId) === key);
  670. if (inTrial) return [inTrial, ...filtered];
  671. return [
  672. {
  673. userId: key,
  674. id: key,
  675. name: this.form.name || "",
  676. employeeNo: this.form.employeeNo || "",
  677. },
  678. ...filtered,
  679. ];
  680. },
  681. },
  682. created() {
  683. // 表格挂载后会自动加载第一页(datasource: fetchPage)
  684. this.loadTemplates();
  685. this.loadEmployees();
  686. /* 「已有转正单的员工集合」改为懒加载(loadBillUserIds 会全量分页拉转正单,
  687. 进页面就拉会与列表第一页请求重复,见 ensureBillUserIds) */
  688. this.loadOrganizations();
  689. this.loadPositionNatureOptions();
  690. },
  691. watch: {
  692. drawerVisible(val) {
  693. // 弹框打开时预拉一次直属上级候选,确保自动带出的 manager 能回显;
  694. // 查看详情(readOnly)下拉是禁用的纯回显,不需要候选,省一次 getUserPage
  695. if (val && !this.readOnly) {
  696. this.searchManagers("");
  697. }
  698. },
  699. "form.managerUserId"(val) {
  700. if (!val || !this.drawerVisible) return;
  701. // 详情态:单据里已带出直属上级名称,本地补一条选项即可回显,不再打 getUserPage
  702. if (this.readOnly) {
  703. this.ensureManagerOption();
  704. return;
  705. }
  706. // selectEmployee 异步带出 managerUserId 时,确保 options 里有对应选项
  707. this.searchManagers("");
  708. },
  709. },
  710. methods: {
  711. statusLabel(status) {
  712. return DISPLAY_STATUS_LABELS[status] || status || "-";
  713. },
  714. countStatus(s) {
  715. return s === "all"
  716. ? this.rows.length
  717. : this.rows.filter((r) => r.status === s).length;
  718. },
  719. canApply(row) {
  720. return ["pending_apply", "draft", "rejected", "withdrawn"].includes(
  721. row.status,
  722. );
  723. },
  724. /** 可提交审批:草稿/已撤回/已驳回/已取消 + 有转正单主键(已取消后可重新编辑发起) */
  725. canEdit(row) {
  726. return Boolean(row.regularizationId) && ["draft", "withdrawn", "rejected", "cancelled"].includes(row.status);
  727. },
  728. canSubmit(row) {
  729. return (
  730. row.regularizationId &&
  731. ["draft", "withdrawn", "rejected", "cancelled"].includes(row.status)
  732. );
  733. },
  734. /** 可查看审批记录:必须有有效的审批实例 id(approvalInstanceId / processInstanceId) */
  735. canViewApproval(row) {
  736. return Boolean(
  737. row?.approvalInstanceId || row?.processInstanceId,
  738. );
  739. },
  740. canCancel(row) {
  741. return (
  742. row.regularizationId &&
  743. ["draft", "pending_apply", "withdrawn", "rejected"].includes(row.status)
  744. );
  745. },
  746. applyFilters() {
  747. this.filters = {
  748. userId: this.draftFilters.userId,
  749. organizationId: this.draftFilters.organizationId,
  750. regularizationType: this.draftFilters.regularizationType,
  751. dateRange: this.draftFilters.dateRange
  752. ? [...this.draftFilters.dateRange]
  753. : [],
  754. effectiveRange: this.draftFilters.effectiveRange
  755. ? [...this.draftFilters.effectiveRange]
  756. : [],
  757. };
  758. this.reloadTable();
  759. },
  760. resetFilters() {
  761. this.draftFilters = emptyFilters();
  762. this.filters = emptyFilters();
  763. this.activeStatus = "all";
  764. this.reloadTable();
  765. },
  766. async loadTemplates() {
  767. this.templateLoading = true;
  768. try {
  769. const data = await getRegularizationTemplatePage({
  770. pageNum: 1,
  771. size: 200,
  772. templateStatus: "ENABLED",
  773. });
  774. this.enabledTemplates = Array.isArray(data?.list) ? data.list : [];
  775. } catch (error) {
  776. this.enabledTemplates = [];
  777. showRequestError(error, "转正模板加载失败");
  778. } finally {
  779. this.templateLoading = false;
  780. }
  781. },
  782. /** 已生效的查询条件 → 分页查询参数(POST /main/regularizations/page) */
  783. listQuery(extra = {}) {
  784. const plan = this.filters.dateRange || [];
  785. const effective = this.filters.effectiveRange || [];
  786. return {
  787. userId: this.filters.userId || undefined,
  788. // Swagger:organizationId 按机构筛选(含全部下级部门),deptId 是部门主键
  789. organizationId: this.filters.organizationId || undefined,
  790. regularizationType: this.filters.regularizationType || undefined,
  791. // Swagger 只有 regularDateStart/End(转正日期),前端计划/生效区间合起来
  792. regularDateStart:
  793. plan[0] || effective[0] || undefined,
  794. regularDateEnd: plan[1] || effective[1] || undefined,
  795. sortName: "createTime",
  796. orderBy: "descending",
  797. ...(STATUS_TAB_QUERY[this.activeStatus] || {}),
  798. ...extra,
  799. };
  800. },
  801. reloadTable() {
  802. this.selectedRows = [];
  803. this.billUserIds = null;
  804. this.$nextTick(() => this.$refs.table?.reload({ page: 1 }));
  805. },
  806. /** 列表数据源:转正单分页接口 + 待申请(试用期员工本地拼装) */
  807. async fetchPage({ page, limit }) {
  808. const pageNum = Number(page) || 1;
  809. const size = Number(limit) || 8;
  810. this.loading = true;
  811. try {
  812. if (this.activeStatus === "pending_apply") {
  813. const all = await this.buildPendingRows();
  814. const start = (pageNum - 1) * size;
  815. this.rows = all;
  816. this.totalCount = all.length;
  817. return { list: all.slice(start, start + size), count: all.length };
  818. }
  819. const data = await getRegularizationPage(
  820. this.listQuery({ pageNum, size, offset: (pageNum - 1) * size }),
  821. );
  822. /* ★ 不再逐行回查员工档案(原 withRegularizationEmployeeInfo,属前端兜底 + N+1),
  823. 接口没返回的字段由 row.fieldIssues 带出并明确提示后端补返回 */
  824. const list = Array.isArray(data.list) ? data.list : [];
  825. this.rows = list;
  826. this.totalCount = Number(data.count ?? list.length);
  827. const issues = [...new Set(list.flatMap((row) => row.fieldIssues || []))];
  828. if (issues.length) {
  829. this.$message.warning(`转正单接口未返回字段:${issues.join("、")},请后端补充返回`);
  830. }
  831. return { list, count: this.totalCount };
  832. } catch (error) {
  833. this.rows = [];
  834. this.totalCount = 0;
  835. showRequestError(error, "转正列表加载失败");
  836. return { list: [], count: 0 };
  837. } finally {
  838. this.loading = false;
  839. }
  840. },
  841. /**
  842. * 待申请:试用期员工里还没有转正单的人。
  843. * 接口没有「待申请」这种状态,所以先取试用期员工,再排除已有转正单的员工。
  844. */
  845. async buildPendingRows() {
  846. const employees = await this.loadEmployees();
  847. await this.ensureBillUserIds();
  848. const f = this.filters;
  849. const plan = f.dateRange || [];
  850. const deptKey =
  851. f.organizationId == null ? "" : String(f.organizationId);
  852. const parents = new Map(
  853. this.orgOptions.map((item) => [
  854. String(item.id),
  855. item.parentId == null ? "" : String(item.parentId),
  856. ]),
  857. );
  858. // 选中上级机构时,其下级部门也要命中
  859. const underSelectedOrg = (emp) => {
  860. if (!deptKey) return true;
  861. const keys = [emp.deptId, ...(emp.deptIds || [])]
  862. .filter((id) => id !== null && id !== undefined && id !== "")
  863. .map((id) => String(id));
  864. return keys.some((key) => {
  865. let cursor = key;
  866. let guard = 0;
  867. while (cursor && guard < 20) {
  868. if (cursor === deptKey) return true;
  869. cursor = parents.get(cursor) || "";
  870. guard += 1;
  871. }
  872. return false;
  873. });
  874. };
  875. return employees
  876. .filter((emp) => !this.billUserIds.has(String(emp.userId)))
  877. .filter((emp) => !f.userId || String(emp.userId) === String(f.userId))
  878. .filter(underSelectedOrg)
  879. .filter((emp) => {
  880. const date = emp.plannedRegularDate || emp.plannedDate || "";
  881. return !plan.length || (date && date >= plan[0] && date <= plan[1]);
  882. })
  883. .map((emp) => ({ ...emp, id: `pending-${emp.userId}` }));
  884. },
  885. /**
  886. * 「已有转正单的员工集合」懒加载入口:
  887. * 只在切到「待申请」页签、或打开新建申请弹窗(申请员工下拉要剔除已申请的人)时拉取一次,
  888. * 避免进页面就全量分页拉转正单、与列表第一页请求重复。
  889. */
  890. async ensureBillUserIds() {
  891. if (this.billUserIds) return this.billUserIds;
  892. this.billUserIds = await this.loadBillUserIds();
  893. return this.billUserIds;
  894. },
  895. /** 已有转正单的员工 id 集合(分页拉全量,用于「待申请」差集) */
  896. async loadBillUserIds() {
  897. const ids = new Set();
  898. const size = 200;
  899. let pageNum = 1;
  900. let total = Infinity;
  901. while (pageNum <= 10 && ids.size < total) {
  902. const { list, count } = await getRegularizationPage({ pageNum, size });
  903. total = Number(count) || 0;
  904. if (!list.length) break;
  905. list.forEach((item) => {
  906. if (item.userId !== null && item.userId !== undefined && item.userId !== "") {
  907. ids.add(String(item.userId));
  908. }
  909. });
  910. if (list.length < size) break;
  911. pageNum += 1;
  912. }
  913. return ids;
  914. },
  915. /**
  916. * 试用期状态码:从员工状态字典 hr_user_status 里按文案「试用期」取(value 即数字状态码)。
  917. * ★ 原先硬编码 status:"5" 是错的(字典口径 6=试用期);这里不再猜码:
  918. * 字典没配「试用期」或字典加载失败 → 直接抛错,由 loadEmployees 明确提示。
  919. */
  920. async resolveProbationStatusCode() {
  921. const options = await loadEmployeeStatusOptions();
  922. const hit = options.find((item) => String(item.label || "").includes("试用期"));
  923. if (!hit) {
  924. throw new Error(
  925. "员工状态字典(hr_user_status)中未找到「试用期」选项,请在字典管理中配置后再使用转正办理",
  926. );
  927. }
  928. return hit.value;
  929. },
  930. /** 试用期员工(转正申请可选人员 / 待申请列表数据源) */
  931. async loadEmployees(force = false) {
  932. if (!force && this.trialEmployees.length) return this.trialEmployees;
  933. try {
  934. /* 状态码取自字典(试用期),不硬编码 */
  935. const probationStatusCode = await this.resolveProbationStatusCode();
  936. const { list } = await fetchAllUsers(
  937. { status: probationStatusCode, isQueryLZ: 0 },
  938. { pageSize: 200, maxPages: 30 },
  939. );
  940. this.trialEmployees = (list || []).map((item) => {
  941. const employee = adaptEmployee(item);
  942. const row = mergeEmployeeRegularization(employee, null);
  943. return {
  944. ...row,
  945. userId: employee.id,
  946. deptIds: employee.deptIds || [],
  947. };
  948. });
  949. } catch (error) {
  950. this.trialEmployees = [];
  951. showRequestError(error, "试用期员工加载失败");
  952. }
  953. return this.trialEmployees;
  954. },
  955. async loadOrganizations() {
  956. // 机构/部门基础数据与级联下拉共用一次请求
  957. if (!this.orgList.length) await this.loadCascadeOrganizations();
  958. // 全量组织(含部门)平铺:仅用于「待申请」按机构上溯匹配员工部门;
  959. // 筛选区「所属机构」下拉统一用 mixin 的 legalEntities(机构,不放部门)
  960. this.orgOptions = this.orgList.map((item) => ({
  961. id: item.id,
  962. name: item.name,
  963. parentId: item.parentId,
  964. }));
  965. },
  966. /** 加载岗位性质字典(position_nature);getDictByCode 已归一两种返回结构,无需再解析 res.data */
  967. async loadPositionNatureOptions() {
  968. try {
  969. const items = await getDictByCode("position_nature");
  970. this.positionNatureOptions = (Array.isArray(items) ? items : [])
  971. .map((item) => {
  972. const label = String(item.label ?? "").trim();
  973. /* 提交口径与岗位档案一致:字典主键 id 优先,其次老格式的 map key */
  974. const value = item.id != null && item.id !== "" ? item.id : item.key;
  975. if (!label || value == null || value === "") return null;
  976. /* 选项值统一字符串:接口编码多为数字,回填值是字符串时不转类型匹配不上 */
  977. return {
  978. id: String(value),
  979. value: String(value),
  980. key: item.key != null ? String(item.key) : String(value),
  981. label,
  982. };
  983. })
  984. .filter(Boolean);
  985. } catch (error) {
  986. this.positionNatureOptions = [];
  987. }
  988. },
  989. /** 岗位性质字典项:兼容「字典值/编码」与「历史名称文案」两种输入 */
  990. findPositionNature(raw) {
  991. const text = String(raw ?? "").trim();
  992. if (!text) return null;
  993. return (
  994. this.positionNatureOptions.find(
  995. (item) =>
  996. String(item.id) === text ||
  997. String(item.value) === text ||
  998. String(item.key) === text ||
  999. String(item.label) === text,
  1000. ) || null
  1001. );
  1002. },
  1003. /** 岗位性质名称/字典值 → 字典值(字符串,保证与下拉选项同类型) */
  1004. matchPositionTypeId(label) {
  1005. const hit = this.findPositionNature(label);
  1006. return hit ? String(hit.id) : "";
  1007. },
  1008. /** 岗位性质下拉切换:同步 id 与名称 */
  1009. onPositionTypeChange(value) {
  1010. const matched = this.positionNatureOptions.find(
  1011. (item) => String(item.id) === String(value),
  1012. );
  1013. this.form.positionType = matched ? matched.id : "";
  1014. this.form.positionTypeName = matched ? matched.label : "";
  1015. },
  1016. async searchEmployees(keyword) {
  1017. this.employeeLoading = true;
  1018. try {
  1019. const data = await searchUsersByKeyword(keyword, {
  1020. pageNum: 1,
  1021. size: 30,
  1022. });
  1023. const list = Array.isArray(data?.list) ? data.list : [];
  1024. this.employeeOptions = list.map((item) => {
  1025. const employee = adaptEmployee(item);
  1026. return { ...employee, id: String(employee.id) };
  1027. });
  1028. } catch (error) {
  1029. this.employeeOptions = [];
  1030. } finally {
  1031. this.employeeLoading = false;
  1032. }
  1033. },
  1034. async searchManagers(keyword) {
  1035. this.managerLoading = true;
  1036. try {
  1037. const data = await searchUsersByKeyword(keyword || "", {
  1038. pageNum: 1,
  1039. size: 30,
  1040. });
  1041. const list = Array.isArray(data?.list) ? data.list : [];
  1042. this.managerOptions = list.map((item) => {
  1043. const employee = adaptEmployee(item);
  1044. return {
  1045. ...employee,
  1046. id: String(employee.id),
  1047. name: employee.name || employee.employeeName || "",
  1048. employeeNo: employee.employeeNo || employee.jobNumber || "",
  1049. };
  1050. });
  1051. // 回显兜底:如果 form.managerUserId 有值但选项里没匹配上,加一条假选项让 el-select 能显示
  1052. this.ensureManagerOption();
  1053. } catch (error) {
  1054. this.managerOptions = [];
  1055. } finally {
  1056. this.managerLoading = false;
  1057. }
  1058. },
  1059. /** 直属上级回显:选项里没有当前 managerUserId 时本地补一条,不发请求 */
  1060. ensureManagerOption() {
  1061. const val = this.form?.managerUserId;
  1062. if (!val) return;
  1063. const hasMatch = this.managerOptions.some(
  1064. (it) => String(it.id) === String(val),
  1065. );
  1066. if (!hasMatch) {
  1067. this.managerOptions.unshift({
  1068. id: String(val),
  1069. name: this.form.managerUserName || this.form.manager || "已离职员工",
  1070. employeeNo: "",
  1071. });
  1072. }
  1073. },
  1074. onManagerChange(userId) {
  1075. if (!userId) {
  1076. this.form.managerUserId = null;
  1077. this.form.managerUserName = "";
  1078. this.form.manager = "";
  1079. return;
  1080. }
  1081. const match =
  1082. this.managerOptions.find((it) => String(it.id) === String(userId)) || {};
  1083. this.form.managerUserId = String(userId);
  1084. this.form.managerUserName = match.name || "";
  1085. this.form.manager = match.name || "";
  1086. },
  1087. /** 行 / 详情数据 → 规范签署方式:ONLINE 线上 / OFFLINE 线下 */
  1088. normalizeSigningMethod(raw = {}) {
  1089. const method = String(raw.signingMethod || raw.signMethod || "")
  1090. .trim()
  1091. .toUpperCase();
  1092. if (method === "ONLINE" || method === "OFFLINE") return method;
  1093. // 老数据没有签署方式字段时,按线上单据字段推断
  1094. return raw.online === true ||
  1095. raw.onlineDocumentTemplateId ||
  1096. raw.onlineDocumentId
  1097. ? "ONLINE"
  1098. : "OFFLINE";
  1099. },
  1100. /** 行 / 详情数据 → 表单签署方式与线上开关 */
  1101. signStateOf(raw = {}) {
  1102. const signingMethod = this.normalizeSigningMethod(raw);
  1103. return { signingMethod, online: signingMethod === "ONLINE" };
  1104. },
  1105. /** 线上模板功能未开通的统一提示:先关掉上一条,避免连点堆叠多条提示 */
  1106. warnTemplateDisabled() {
  1107. this.$message.closeAll();
  1108. this.$message.warning("暂无开通模板功能,敬请期待");
  1109. },
  1110. /** 转正申请模板下拉展开时同样提示(历史线上转正单该字段仍会渲染) */
  1111. onTemplateDropdown(visible) {
  1112. if (visible) this.warnTemplateDisabled();
  1113. },
  1114. /** 切换签署方式:同时更新 signingMethod 与线上开关,并清掉另一侧取值 */
  1115. setSigningMethod(method) {
  1116. const next = String(method).toUpperCase() === "ONLINE" ? "ONLINE" : "OFFLINE";
  1117. if (next === "ONLINE") {
  1118. // 线上模板未开通:只提示,不切换签署方式
  1119. this.warnTemplateDisabled();
  1120. return;
  1121. }
  1122. if (this.form.signingMethod === next) return;
  1123. this.form.signingMethod = next;
  1124. this.form.online = next === "ONLINE";
  1125. // 线上模板与线下附件互斥:切换时两侧都清空,避免提交时同时带上模板与附件
  1126. this.form.onlineDocumentTemplateId = null;
  1127. this.form.offlineFileIds = [];
  1128. this.form.approvalDocumentFileId = null;
  1129. this.offlineDocIds = [];
  1130. this.$nextTick(() => {
  1131. this.$refs.applyForm?.clearValidate(["onlineDocumentTemplateId", "offlineFileIds"]);
  1132. });
  1133. },
  1134. /** 取员工的转正单主键:优先列表行,其次分页接口,最后本地缓存兜底 */
  1135. async resolveBillId(row = {}) {
  1136. if (row.regularizationId) return row.regularizationId;
  1137. const userId = row.userId || this.form.userId;
  1138. if (!userId) return null;
  1139. try {
  1140. const bill = await resolveRegularizationForUser(userId);
  1141. if (bill?.regularizationId) return bill.regularizationId;
  1142. } catch (error) {
  1143. /* 接口不可用时回退本地缓存 */
  1144. }
  1145. return resolveRegularizationIdFromHistory(userId);
  1146. },
  1147. async fillEmploymentPeriod(userId) {
  1148. try {
  1149. const periods = await getEmploymentPeriods(userId);
  1150. const list = Array.isArray(periods)
  1151. ? periods
  1152. : Array.isArray(periods?.list)
  1153. ? periods.list
  1154. : [];
  1155. const active =
  1156. list.find((p) => String(p.periodStatus || "").toUpperCase() === "ACTIVE") ||
  1157. list[0];
  1158. if (!active) return;
  1159. // json-bigint storeAsString 后 id 应为字符串,强制 String 兜底防精度丢失
  1160. const id = String(active.id ?? "").trim();
  1161. if (id && /^\d+$/.test(id)) {
  1162. this.form.employmentPeriodId = id;
  1163. }
  1164. if (!this.form.plannedRegularDate) {
  1165. this.form.plannedRegularDate =
  1166. (active.probationEndDate || active.regularDate || "").slice(0, 10) ||
  1167. "";
  1168. this.form.plannedDate = this.form.plannedRegularDate;
  1169. }
  1170. } catch (e) {
  1171. // 任职周期可选
  1172. }
  1173. },
  1174. async selectEmployee(userId) {
  1175. const row =
  1176. this.trialEmployees.find((x) => String(x.userId) === String(userId)) ||
  1177. this.rows.find((x) => String(x.userId) === String(userId));
  1178. if (row) {
  1179. this.form = {
  1180. ...emptyForm(),
  1181. ...JSON.parse(JSON.stringify(row)),
  1182. regularizationId: null,
  1183. approvalInstanceId: null,
  1184. approvalStatus: "",
  1185. approvalStatusLabel: "未提交",
  1186. status: "",
  1187. statusLabel: "",
  1188. userId: row.userId,
  1189. plannedRegularDate:
  1190. row.plannedRegularDate || row.plannedDate || row.regularDate || "",
  1191. effectiveDate: row.effectiveDate || "",
  1192. ...this.signStateOf({}),
  1193. };
  1194. } else {
  1195. try {
  1196. const raw = await getEmployeeById(userId);
  1197. const emp = adaptEmployee(raw || {});
  1198. this.form = {
  1199. ...emptyForm(),
  1200. ...mergeEmployeeRegularization(emp, null),
  1201. userId: emp.id,
  1202. };
  1203. } catch (error) {
  1204. showRequestError(error, "员工信息加载失败");
  1205. return;
  1206. }
  1207. }
  1208. // 岗位性质:档案带出编码(兼容历史名称文案),统一反查成「字典值 + 名称」
  1209. const natureOption = this.findPositionNature(
  1210. this.form.positionType || this.form.positionTypeName,
  1211. );
  1212. this.form.positionType = natureOption ? String(natureOption.id) : "";
  1213. if (natureOption) this.form.positionTypeName = natureOption.label;
  1214. await this.fillEmploymentPeriod(userId);
  1215. // 逐级预热机构/部门/岗位序列/岗位/层级下拉,保证带出的值能正常显示
  1216. await this.primeCascade();
  1217. /* 员工档案没带出机构时,默认登录机构列表第一个 */
  1218. if (!this.form.groupId) this.applyDefaultCascadeOrg();
  1219. // selectEmployee 始终是新建语义(regularizationId=null),
  1220. // 不再 hydrateBill 把旧单状态覆盖回来
  1221. },
  1222. /**
  1223. * 接口字段异常提示(★ 项目约定 2026-10-16:前端不做兜底)
  1224. * 接口没返回的字段不拿别处数据拼凑,只明确提示「该字段接口未返回」,由后端补字段。
  1225. */
  1226. warnFieldIssues(record) {
  1227. const issues = record?.fieldIssues || [];
  1228. if (!issues.length) return;
  1229. this.$message.warning(
  1230. `转正单接口未返回字段:${issues.join("、")},请后端补充返回`,
  1231. );
  1232. },
  1233. async hydrateBill(billId) {
  1234. if (!billId) return;
  1235. try {
  1236. const bill = await getRegularizationById(billId, {
  1237. userId: this.form.userId,
  1238. employeeNo: this.form.employeeNo,
  1239. name: this.form.name,
  1240. phone: this.form.phone,
  1241. company: this.form.company,
  1242. department: this.form.department,
  1243. position: this.form.position,
  1244. level: this.form.level,
  1245. manager: this.form.manager,
  1246. // 机构/部门/岗位主键:后端没返回时用员工档案已带出的值兜底
  1247. groupId: this.form.groupId,
  1248. deptId: this.form.deptId,
  1249. positionId: this.form.positionId,
  1250. // 岗位序列/层级/性质:后端没返回时用员工档案已带出的值兜底
  1251. positionSequenceId: this.form.positionSequenceId,
  1252. positionSequence: this.form.positionSequence,
  1253. positionLevelId: this.form.positionLevelId,
  1254. positionLevel: this.form.positionLevel,
  1255. positionType: this.form.positionType,
  1256. positionTypeName: this.form.positionTypeName,
  1257. });
  1258. // 签署方式以详情接口 signingMethod 为准(ONLINE 线上 / OFFLINE 线下)
  1259. const signingState = this.signStateOf(bill);
  1260. /* 接口字段异常明确提示(前端不做兜底):字段缺失属接口问题,需后端补返回 */
  1261. this.warnFieldIssues(bill);
  1262. this.form = {
  1263. ...this.form,
  1264. ...bill,
  1265. id: this.form.userId || bill.userId,
  1266. userId: this.form.userId || bill.userId,
  1267. regularizationId: bill.regularizationId || bill.billId || bill.id,
  1268. signingMethod: signingState.signingMethod,
  1269. online: signingState.online,
  1270. // 防御:如果后端返回的 employmentPeriodId 是负值/非数字(精度丢失),保留 fillEmploymentPeriod 已设置的正确值
  1271. employmentPeriodId: (() => {
  1272. const raw = bill.employmentPeriodId;
  1273. if (raw === undefined || raw === null || raw === "")
  1274. return this.form.employmentPeriodId;
  1275. const text = String(raw).trim();
  1276. return /^\d+$/.test(text) ? text : this.form.employmentPeriodId;
  1277. })(),
  1278. };
  1279. // 申请资料(approvalDocumentFileId):多附件主键以英文逗号拼接,回显时拆成数组
  1280. const approvalDocIds = String(bill.approvalDocumentFileId ?? "")
  1281. .split(",")
  1282. .map((id) => id.trim())
  1283. .filter(Boolean);
  1284. this.approvalDocIds = approvalDocIds;
  1285. // 线下附件回显:优先用申请资料字段,其次兼容旧的附件字段
  1286. const offlineRaw = approvalDocIds.length
  1287. ? approvalDocIds
  1288. : bill.offlineFileIds || bill.offlineFileId || bill.attachmentIds;
  1289. this.offlineDocIds = Array.isArray(offlineRaw)
  1290. ? offlineRaw
  1291. : offlineRaw
  1292. ? String(offlineRaw).split(",").filter(Boolean)
  1293. : [];
  1294. } catch (error) {
  1295. showRequestError(error, "转正单加载失败");
  1296. }
  1297. },
  1298. async openCreate(row) {
  1299. this.readOnly = false;
  1300. this.drawerLoading = true;
  1301. this.drawerVisible = true;
  1302. this.approvalDocIds = [];
  1303. this.offlineDocIds = [];
  1304. try {
  1305. if (row) {
  1306. const existingId = row.regularizationId || row.billId || row.id;
  1307. const isEdit = Boolean(existingId);
  1308. this.form = {
  1309. ...emptyForm(),
  1310. ...JSON.parse(JSON.stringify(row)),
  1311. regularizationId: existingId || null,
  1312. approvalInstanceId: null,
  1313. approvalStatus: "",
  1314. userId: row.userId,
  1315. plannedRegularDate:
  1316. row.plannedRegularDate || row.plannedDate || row.regularDate || "",
  1317. ...this.signStateOf(isEdit ? row : {}),
  1318. };
  1319. await this.fillEmploymentPeriod(row.userId);
  1320. if (isEdit) {
  1321. await this.hydrateBill(existingId);
  1322. }
  1323. await this.primeCascade();
  1324. } else {
  1325. this.form = emptyForm();
  1326. /* 新建:申请员工下拉要剔除已有转正单的人,先确保「已有转正单」集合已加载 */
  1327. await this.ensureBillUserIds();
  1328. }
  1329. this.$nextTick(() => this.$refs.applyForm?.clearValidate());
  1330. } finally {
  1331. this.drawerLoading = false;
  1332. }
  1333. },
  1334. async openDetail(row) {
  1335. this.readOnly = true;
  1336. this.drawerLoading = true;
  1337. this.drawerVisible = true;
  1338. try {
  1339. this.form = {
  1340. ...emptyForm(),
  1341. ...JSON.parse(JSON.stringify(row)),
  1342. userId: row.userId,
  1343. ...this.signStateOf(row),
  1344. };
  1345. const billId = await this.resolveBillId(row);
  1346. if (billId) await this.hydrateBill(billId);
  1347. await this.primeCascade();
  1348. } finally {
  1349. this.drawerLoading = false;
  1350. }
  1351. },
  1352. async ensureDraftId() {
  1353. // 模板里「申请资料」文件上传器 v-model 绑的是 offlineDocIds
  1354. // 接口字段 approvalDocumentFileId:多个附件主键以英文逗号拼接
  1355. const docIds = (this.offlineDocIds || [])
  1356. .map((id) => String(id ?? "").trim())
  1357. .filter(Boolean);
  1358. this.form.approvalDocumentFileId = docIds.length ? docIds.join(",") : null;
  1359. const payload = {
  1360. ...this.form,
  1361. regularizationId: this.form.regularizationId,
  1362. userId: this.form.userId,
  1363. };
  1364. if (this.form.regularizationId) {
  1365. await updateRegularization(payload);
  1366. return this.form.regularizationId;
  1367. }
  1368. const id = await createRegularization(payload);
  1369. this.form.regularizationId = id;
  1370. saveRegularizationBillId(this.form.userId, id);
  1371. return id;
  1372. },
  1373. saveDraft() {
  1374. if (!this.form.userId) {
  1375. this.$message.warning("请先选择员工");
  1376. return;
  1377. }
  1378. this.$refs.applyForm.validate(async (valid) => {
  1379. if (!valid) return;
  1380. this.submitting = true;
  1381. try {
  1382. await this.ensureDraftId();
  1383. this.$message.success("草稿已保存");
  1384. this.drawerVisible = false;
  1385. this.reloadTable();
  1386. } catch (error) {
  1387. showRequestError(error, "保存草稿失败");
  1388. } finally {
  1389. this.submitting = false;
  1390. }
  1391. });
  1392. },
  1393. /**
  1394. * 提交转正审批:转正模块 Swagger 没有 /submit endpoint,
  1395. * 先保存草稿,再通过 BPM 公共组件发起审批流程。
  1396. */
  1397. submitApplication() {
  1398. this.$refs.applyForm.validate(async (valid) => {
  1399. if (!valid) return;
  1400. this.submitting = true;
  1401. try {
  1402. const id = await this.ensureDraftId();
  1403. this.submitting = false;
  1404. // 不关抽屉,弹 BPM 发起审批;BPM 提交成功后 @reload 再统一关
  1405. this.launchApproval(this.form);
  1406. } catch (error) {
  1407. this.submitting = false;
  1408. showRequestError(error, "保存草稿失败");
  1409. }
  1410. });
  1411. },
  1412. /** 弹 BPM processSubmitDialog 发起审批 */
  1413. launchApproval(row) {
  1414. const currentUser = getCurrentUser() || {};
  1415. this.processSubmitDialogFlag = true;
  1416. this.$nextTick(() => {
  1417. this.$refs.processSubmitDialogRef?.init({
  1418. businessId: row.regularizationId || row.id,
  1419. businessKey: REGULARIZATION_APPROVAL_BUSINESS_KEY,
  1420. formCreateUserId:
  1421. row.createUserId ||
  1422. currentUser.id ||
  1423. currentUser.userId ||
  1424. "",
  1425. variables: {
  1426. businessCode:
  1427. row.regularizationId ? String(row.regularizationId) : "",
  1428. businessName: row.name || "",
  1429. businessType:
  1430. (row.regularizationTypeLabel || "转正") +
  1431. "转正",
  1432. },
  1433. });
  1434. });
  1435. },
  1436. onProcessSubmitReload() {
  1437. this.drawerVisible = false;
  1438. this.processSubmitDialogFlag = false;
  1439. this.reloadTable();
  1440. },
  1441. /** 查看审批流程 */
  1442. openApprovalRecord(row) {
  1443. // 跟离职一致:直接调 BpmProcessDetailDialog.open,组件内部会从
  1444. // row.processInstanceId / row.approvalInstanceId / row.processInstance 解析 id
  1445. this.$refs.approvalRecordDialog?.open(row);
  1446. },
  1447. cancelRow(row) {
  1448. this.$prompt("请输入取消原因", "取消转正单", {
  1449. inputType: "textarea",
  1450. inputValidator: (v) =>
  1451. Boolean(String(v || "").trim()) || "请填写取消原因",
  1452. })
  1453. .then(async ({ value }) => {
  1454. try {
  1455. await cancelRegularization(row.regularizationId, { reason: value });
  1456. removeRegularizationBillId(row.userId);
  1457. this.$message.success("已取消");
  1458. this.reloadTable();
  1459. } catch (error) {
  1460. showRequestError(error, "取消失败");
  1461. }
  1462. })
  1463. .catch(() => { });
  1464. },
  1465. statusClass(s) {
  1466. return (
  1467. {
  1468. pending_apply: "pending",
  1469. draft: "pending",
  1470. reviewing: "reviewing",
  1471. done: "success",
  1472. rejected: "danger",
  1473. withdrawn: "pending",
  1474. cancelled: "danger",
  1475. }[s] || "pending"
  1476. );
  1477. },
  1478. statusTagType(s) {
  1479. const cls = this.statusClass(s);
  1480. if (cls === "success") return "success";
  1481. if (cls === "danger") return "danger";
  1482. if (cls === "reviewing") return "warning";
  1483. return "info";
  1484. },
  1485. approvalType(s) {
  1486. const code = String(s || "").toUpperCase();
  1487. if (code === "APPROVED") return "success";
  1488. if (code === "REJECTED" || code === "CANCELLED") return "danger";
  1489. if (!code || code === "DRAFT") return "info";
  1490. return "warning";
  1491. },
  1492. dateHint(r) {
  1493. if (r.status === "done") return "已生效";
  1494. if (r.status === "reviewing") return "审批中";
  1495. return "待办理";
  1496. },
  1497. exportRows() {
  1498. const rows = this.selectedRows.length ? this.selectedRows : this.rows;
  1499. const headers = [
  1500. "工号",
  1501. "姓名",
  1502. "所属机构",
  1503. "所属部门",
  1504. "岗位",
  1505. "入职日期",
  1506. "转正日期",
  1507. "转正类型",
  1508. "办理状态",
  1509. "审核状态",
  1510. "创建人",
  1511. "创建时间",
  1512. "修改人",
  1513. "修改时间",
  1514. ];
  1515. const data = rows.map((r) => ({
  1516. 工号: r.employeeNo || "",
  1517. 姓名: r.name || "",
  1518. 所属机构: r.company || "",
  1519. 所属部门: r.department || "",
  1520. 岗位: r.position || "",
  1521. 入职日期: r.hireDate || "",
  1522. 转正日期:
  1523. r.effectiveDate || r.regularDate || r.plannedRegularDate || "",
  1524. 转正类型: r.regularizationTypeLabel || "",
  1525. 办理状态: r.effectiveStatusLabel || r.approvalStatusLabel || "",
  1526. 审核状态: r.approvalStatusLabel || "",
  1527. 创建人: r.createUserName || "",
  1528. 创建时间: r.createTime || "",
  1529. 修改人: r.updateUserName || "",
  1530. 修改时间: r.updateTime || "",
  1531. }));
  1532. const ws = XLSX.utils.json_to_sheet(data, { header: headers });
  1533. // 列宽适配
  1534. ws["!cols"] = [
  1535. { wch: 12 }, // 工号
  1536. { wch: 12 }, // 姓名
  1537. { wch: 28 }, // 所属机构
  1538. { wch: 18 }, // 所属部门
  1539. { wch: 14 }, // 岗位
  1540. { wch: 14 }, // 入职日期
  1541. { wch: 14 }, // 转正日期
  1542. { wch: 12 }, // 转正类型
  1543. { wch: 12 }, // 办理状态
  1544. { wch: 12 }, // 审核状态
  1545. { wch: 10 }, // 创建人
  1546. { wch: 20 }, // 创建时间
  1547. { wch: 10 }, // 修改人
  1548. { wch: 20 }, // 修改时间
  1549. ];
  1550. const wb = XLSX.utils.book_new();
  1551. XLSX.utils.book_append_sheet(wb, ws, "转正办理");
  1552. const fileName = `转正办理_${new Date().toISOString().slice(0, 10)}.xlsx`;
  1553. XLSX.writeFile(wb, fileName);
  1554. },
  1555. },
  1556. };
  1557. </script>
  1558. <style src="../../../styles/views/regularizationHandling/index.scss" scoped lang="scss"></style>
  1559. <style scoped>
  1560. .upload-tip {
  1561. margin: 8px 0 0;
  1562. color: #7e8b9d;
  1563. font-size: 14px;
  1564. }
  1565. </style>