attendanceApply.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import { get, postJ, putJ } from "@/utils/request";
  2. import { processInstanceCreateAPI, processInstancePage } from "@/api/wt/index.js";
  3. import { withTimeout } from "@/api/hr/attendance";
  4. import Vue from "vue";
  5. // 发起审批的请求超时(ms):避免 utils/request 非 JSON 响应下永远挂起
  6. const REQUEST_TIMEOUT = 12000;
  7. // 表单本地 type → 接口 applyType 枚举(HR 侧仅保留以下 5 类)
  8. export const ATTENDANCE_APPLY_TYPE_MAP = {
  9. leave: "LEAVE",
  10. overtime: "OVERTIME",
  11. field: "OUTING", // 外勤
  12. trip: "TRIP",
  13. check: "CARD_REPAIR",
  14. };
  15. // 表单本地 type → 流程业务类型码(与 api/hr/bpm.js 的 BUSINESS_TYPE_LABELS 对齐)
  16. export const ATTENDANCE_BUSINESS_TYPE = {
  17. leave: "ATTENDANCE_LEAVE",
  18. overtime: "ATTENDANCE_OVERTIME",
  19. field: "ATTENDANCE_OUTING",
  20. trip: "ATTENDANCE_TRIP",
  21. check: "ATTENDANCE_CARD_REPAIR",
  22. };
  23. /**
  24. * 考勤申请审批流程标识 —— 五类申请各用各的,与流程定义 key 一一对应:
  25. * 请假 → hr_attendance_leave_apply
  26. * 加班 → hr_attendance_overtime_apply
  27. * 外勤 → hr_attendance_outing_apply
  28. * 出差 → hr_attendance_trip_apply
  29. * 补卡 → hr_attendance_card_repair_apply
  30. */
  31. export const ATTENDANCE_APPROVAL_BUSINESS_KEYS = {
  32. leave: "hr_attendance_leave_apply",
  33. overtime: "hr_attendance_overtime_apply",
  34. field: "hr_attendance_outing_apply", // 外勤
  35. trip: "hr_attendance_trip_apply",
  36. check: "hr_attendance_card_repair_apply",
  37. };
  38. export const ATTENDANCE_APPLY_TYPE_KEYS = Object.keys(ATTENDANCE_APPLY_TYPE_MAP);
  39. // 请假类型中文标签 → 接口 leaveType 枚举
  40. // 注:请假类型候选已改由 GET /hr/ess/attendance/leave-rules 提供,
  41. // 此表仅用于兼容历史草稿/历史单据里的中文标签。
  42. export const LEAVE_TYPE_MAP = {
  43. 年假: "ANNUAL",
  44. 事假: "PERSONAL",
  45. 病假: "SICK",
  46. 调休假: "COMPENSATORY",
  47. 调休: "COMPENSATORY",
  48. 婚假: "MARRIAGE",
  49. 产假: "MATERNITY",
  50. 陪产假: "PATERNITY",
  51. 丧假: "BEREAVEMENT",
  52. 工伤假: "INJURY",
  53. 其他: "OTHER",
  54. };
  55. // 接口 leaveType 枚举 → 中文标签(详情回显用,与 LEAVE_TYPE_MAP 互为逆映射)
  56. export const LEAVE_TYPE_LABEL = Object.keys(LEAVE_TYPE_MAP).reduce((acc, key) => {
  57. acc[LEAVE_TYPE_MAP[key]] = key;
  58. return acc;
  59. }, {});
  60. /**
  61. * 请假类型归一化为接口编码:
  62. * 接口/详情回传的 ANNUAL、SICK 等编码原样转大写;
  63. * 历史草稿/历史单据里的中文标签(如「调休假」)按 LEAVE_TYPE_MAP 转一次。
  64. */
  65. export function normalizeLeaveTypeCode(value) {
  66. const raw = String(value == null ? "" : value).trim();
  67. if (!raw) return "";
  68. if (LEAVE_TYPE_MAP[raw]) return LEAVE_TYPE_MAP[raw];
  69. return raw.toUpperCase();
  70. }
  71. // 补卡类型枚举(对应后端 repairType 字段),仅 CARD_REPAIR 申请使用
  72. export const REPAIR_TYPE_LABEL = {
  73. MISSING_IN: "上班漏打卡",
  74. MISSING_OUT: "下班漏打卡",
  75. LATE: "迟到补卡",
  76. EARLY: "早退补卡",
  77. ABSENT: "缺勤补卡",
  78. OTHER: "其他补卡",
  79. NORMAL: "正常补卡",
  80. };
  81. export const REPAIR_TYPE_OPTIONS = Object.keys(REPAIR_TYPE_LABEL);
  82. const DEFAULT_START_TIME = "08:30";
  83. const DEFAULT_END_TIME = "17:30";
  84. export function combineDateTime(dateStr, timeStr) {
  85. if (!dateStr) return "";
  86. if (!timeStr) return `${dateStr}T00:00:00.000`;
  87. return `${dateStr}T${timeStr}:00.000`;
  88. }
  89. function buildRangeTime(dateStr, timeStr) {
  90. if (!dateStr) return "";
  91. return combineDateTime(dateStr, timeStr || "00:00");
  92. }
  93. /** 时长下限(分钟):不足 0.5 小时按 0.5 小时计 */
  94. export const MIN_DURATION_MINUTES = 30;
  95. /**
  96. * 按起止日期时间计算时长(分钟):
  97. * - 不足 30 分钟(0.5 小时)按 30 分钟计;
  98. * - 起止无效或结束早于开始时返回 0(提交前校验会拦截该情况)。
  99. */
  100. export function computeDurationMinutes(startDate, startTime, endDate, endTime) {
  101. if (!startDate || !endDate) return 0;
  102. const start = new Date(`${startDate}T${startTime || "00:00"}:00`);
  103. const end = new Date(`${endDate}T${endTime || "00:00"}:00`);
  104. if (isNaN(start.getTime()) || isNaN(end.getTime())) return 0;
  105. if (end < start) return 0;
  106. return Math.max(MIN_DURATION_MINUTES, Math.floor((end.getTime() - start.getTime()) / 60000));
  107. }
  108. /**
  109. * 时长(小时):接口按时长的小时值接收(如 9,而不是 540 分钟),
  110. * 内部仍用 computeDurationMinutes 计算,提交前换算为小时,保留 2 位小数。
  111. */
  112. function computeDurationHours(startDate, startTime, endDate, endTime) {
  113. const minutes = computeDurationMinutes(startDate, startTime, endDate, endTime);
  114. return Number((minutes / 60).toFixed(2));
  115. }
  116. /** 拆分接口时间值为 { date, time }(time 精确到分钟) */
  117. export function splitDateTime(value) {
  118. const text = String(value || "").replace("T", " ").trim();
  119. if (!text) return { date: "", time: "" };
  120. const parts = text.split(" ");
  121. return { date: parts[0] || "", time: (parts[1] || "").slice(0, 5) };
  122. }
  123. /**
  124. * 接口数据 → 表单数据(详情/编辑回填)
  125. * 入参可以是考勤申请详情 VO,也可以是流程发起时透传的 formVariables(字段同构)
  126. */
  127. export function hydrateAttendanceForm(source = {}, type = "leave") {
  128. const src = source || {};
  129. const start = splitDateTime(src.startTime);
  130. const end = splitDateTime(src.endTime);
  131. const attachmentIds = String(src.attachmentFileIds || "")
  132. .split(",")
  133. .map((item) => item.trim())
  134. .filter(Boolean);
  135. const base = {
  136. reason: src.reason || "",
  137. attachmentIds,
  138. };
  139. if (type === "check") {
  140. const punch = splitDateTime(src.repairPunchTime || src.startTime);
  141. return Object.assign(base, {
  142. startDate: punch.date || start.date,
  143. repairType: src.repairType || "",
  144. repairTime: punch.time || start.time,
  145. });
  146. }
  147. const form = Object.assign(base, {
  148. startDate: start.date,
  149. startTime: start.time || DEFAULT_START_TIME,
  150. endDate: end.date || start.date,
  151. endTime: end.time || DEFAULT_END_TIME,
  152. });
  153. if (type === "leave") {
  154. // form.leaveType 统一存接口编码(ANNUAL/SICK…),老数据的「年假」等中文标签转一次
  155. form.leaveType = normalizeLeaveTypeCode(src.leaveType);
  156. }
  157. return form;
  158. }
  159. /**
  160. * 把表单数据按 applyType 映射成 /hr/attendance/applications 接口参数。
  161. * @param {object} form 表单数据
  162. * @param {string} type 表单类型(leave / overtime / field / trip / check)
  163. * @param {object} userRaw storage 中的 userInfo
  164. * @param {string} attachmentFileIds 已上传附件的 id,英文逗号分隔
  165. */
  166. export function buildAttendancePayload(form, type, userRaw, attachmentFileIds) {
  167. const applyType = ATTENDANCE_APPLY_TYPE_MAP[type];
  168. if (!applyType) return null;
  169. const user = userRaw || {};
  170. const userId = user.userId || user.id || "";
  171. const userName = user.userName || user.name || "";
  172. const base = {
  173. userId,
  174. userName,
  175. applyType,
  176. attachmentFileIds: attachmentFileIds || "",
  177. };
  178. switch (type) {
  179. case "leave":
  180. return Object.assign({}, base, {
  181. leaveType: normalizeLeaveTypeCode(form.leaveType) || "OTHER",
  182. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  183. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  184. durationMinutes: computeDurationHours(
  185. form.startDate,
  186. form.startTime || DEFAULT_START_TIME,
  187. form.endDate,
  188. form.endTime || DEFAULT_END_TIME
  189. ),
  190. reason: form.reason,
  191. });
  192. case "overtime":
  193. return Object.assign({}, base, {
  194. overtimeType: "WORKDAY",
  195. startTime: buildRangeTime(form.startDate, form.startTime || "18:00"),
  196. endTime: buildRangeTime(form.endDate, form.endTime || "20:00"),
  197. durationMinutes: computeDurationHours(
  198. form.startDate,
  199. form.startTime || "18:00",
  200. form.endDate,
  201. form.endTime || "20:00"
  202. ),
  203. reason: form.reason,
  204. });
  205. case "trip":
  206. return Object.assign({}, base, {
  207. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  208. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  209. durationMinutes: computeDurationHours(
  210. form.startDate,
  211. form.startTime || DEFAULT_START_TIME,
  212. form.endDate,
  213. form.endTime || DEFAULT_END_TIME
  214. ),
  215. needPunch: 0,
  216. reason: form.reason,
  217. });
  218. case "field":
  219. return Object.assign({}, base, {
  220. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  221. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  222. durationMinutes: computeDurationHours(
  223. form.startDate,
  224. form.startTime || DEFAULT_START_TIME,
  225. form.endDate,
  226. form.endTime || DEFAULT_END_TIME
  227. ),
  228. syncAsPunch: 0,
  229. reason: form.reason,
  230. });
  231. case "check":
  232. return Object.assign({}, base, {
  233. startTime: combineDateTime(form.startDate, form.repairTime),
  234. repairPunchTime: combineDateTime(form.startDate, form.repairTime),
  235. repairType: form.repairType,
  236. reason: form.reason,
  237. });
  238. default:
  239. return null;
  240. }
  241. }
  242. /**
  243. * 提取建单接口返回的申请主键(发起审批的 businessId 必须是主键本身)。
  244. * 兼容后端返回:标量("1234567890" / 1234567890)、{ id } / { applicationId } /
  245. * { businessId } / { data: { id } } 等结构。
  246. */
  247. export function pickApplicationId(data) {
  248. if (data === undefined || data === null || data === "") return "";
  249. if (typeof data === "string" || typeof data === "number") return String(data);
  250. const keys = ["id", "applicationId", "businessId", "pkId"];
  251. for (const key of keys) {
  252. const value = data[key];
  253. if (value !== undefined && value !== null && value !== "") return String(value);
  254. }
  255. if (data.data) return pickApplicationId(data.data);
  256. return "";
  257. }
  258. /**
  259. * 提交考勤类申请(请假 / 加班 / 出差 / 外出 / 补卡)
  260. * POST /hr/attendance/applications
  261. * @returns 接口原始返回(申请主键用 pickApplicationId 提取)
  262. */
  263. export async function submitAttendanceApplication(payload, loding = true) {
  264. const body = Object.assign({}, payload);
  265. if (!body.applyType) body.applyType = "CARD_REPAIR";
  266. const res = await withTimeout(
  267. postJ(Vue.prototype.apiUrl + "/hr/attendance/applications", body, loding),
  268. REQUEST_TIMEOUT,
  269. "提交申请超时,请检查网络后重试(申请可能已保存,请到“我的申请”确认)"
  270. );
  271. if (res.code == 0) return res.data || {};
  272. return Promise.reject(new Error(res.message || "申请提交失败"));
  273. }
  274. /**
  275. * 考勤申请详情
  276. * GET /hr/attendance/applications/{id}
  277. * 注:后端若未开放该查询,调用方应回退到流程实例的 formVariables(见 loadAttendanceApplicationForEdit)
  278. */
  279. export async function getAttendanceApplicationDetail(id, loding = false) {
  280. if (id === undefined || id === null || id === "") return null;
  281. const res = await get(
  282. Vue.prototype.apiUrl + `/hr/attendance/applications/${encodeURIComponent(String(id))}`,
  283. {},
  284. loding
  285. );
  286. if (res.code == 0) return res.data || null;
  287. return Promise.reject(new Error(res.message || "查询考勤申请详情失败"));
  288. }
  289. /**
  290. * 请假类型列表
  291. * GET /hr/ess/attendance/leave-rules
  292. * 返回结构尚未确认(先用原样 data 暴露给调用方,页面侧打印联调日志)
  293. */
  294. export async function queryLeaveRules(loding = false) {
  295. const res = await withTimeout(
  296. get(Vue.prototype.apiUrl + `/hr/ess/attendance/leave-rules`, {}, loding),
  297. REQUEST_TIMEOUT,
  298. "请假类型查询超时(12s 无响应)"
  299. );
  300. if (!(res && res.code == 0)) {
  301. throw new Error(
  302. (res && res.message) || `请假类型查询失败(code=${res ? res.code : "无响应"})`
  303. );
  304. }
  305. return res.data;
  306. }
  307. /**
  308. * 修改被驳回的考勤申请
  309. * PUT /hr/attendance/applications/{id}
  310. */
  311. export async function updateAttendanceApplication(id, payload, loding = false) {
  312. if (id === undefined || id === null || id === "") throw new Error("缺少申请主键");
  313. const body = Object.assign({}, payload);
  314. if (!body.applyType) body.applyType = "CARD_REPAIR";
  315. const res = await putJ(
  316. Vue.prototype.apiUrl + `/hr/attendance/applications/${encodeURIComponent(String(id))}`,
  317. body,
  318. loding
  319. );
  320. if (res.code == 0) return res.data;
  321. return Promise.reject(new Error(res.message || "考勤申请修改失败"));
  322. }
  323. /**
  324. * 编辑态取数:优先用考勤申请详情接口,失败时回退到流程实例的 formVariables
  325. * (发起审批时已把整份载荷作为 variables 透传,字段与详情 VO 同构)
  326. */
  327. export async function loadAttendanceApplicationForEdit(businessId, processInstanceId = "") {
  328. if (businessId !== undefined && businessId !== null && businessId !== "") {
  329. try {
  330. const detail = await getAttendanceApplicationDetail(businessId, false);
  331. if (detail && (detail.startTime || detail.reason || detail.applyType)) return detail;
  332. } catch (error) {
  333. // 详情接口不可用时走流程变量兜底
  334. }
  335. }
  336. if (!processInstanceId) return null;
  337. const res = await get(
  338. Vue.prototype.apiUrl +
  339. `/bpm/process-instance/get?id=${encodeURIComponent(String(processInstanceId))}`,
  340. {},
  341. false
  342. );
  343. if (res.code == 0) return (res.data && res.data.formVariables) || null;
  344. return null;
  345. }
  346. /**
  347. * 发起考勤申请审批流程。
  348. *
  349. * 五类申请各用各的流程标识(ATTENDANCE_APPROVAL_BUSINESS_KEYS[type]),
  350. * 与其它业务流程一致走两段式:
  351. * 1. GET 流程定义列表(按 key 匹配)取 processDefinition.id;
  352. * 2. POST /bpm/process-instance/create,带 processDefinitionId。
  353. * businessId 为考勤申请主键;variables 同时作为审批详情页的表单数据来源。
  354. *
  355. * @param {string} businessId 考勤申请主键
  356. * @param {object} variables 申请载荷(含 businessType / businessName / deptName 等)
  357. * @param {boolean} loding 历史参数(loading 由调用方控制),保留以兼容既有调用方式
  358. * @param {string} type 表单类型(leave / overtime / field / trip / check),决定用哪个流程标识
  359. */
  360. export async function startAttendanceApproval(businessId, variables = {}, loding = false, type = "") {
  361. if (!businessId) throw new Error("缺少申请主键,无法发起审批");
  362. const businessKey = ATTENDANCE_APPROVAL_BUSINESS_KEYS[type];
  363. if (!businessKey) throw new Error(`未知的考勤申请类型:${type || "(空)"},无法发起审批`);
  364. // 两段式第 1 步:按 key 查流程定义,取最新一条的 processDefinition.id
  365. const page = await withTimeout(
  366. processInstancePage(
  367. { pageNo: 1, pageSize: 1, reset: true, key: businessKey },
  368. false
  369. ),
  370. REQUEST_TIMEOUT,
  371. "查询审批流程定义超时,请检查网络后重试"
  372. );
  373. const first = page && page.list && page.list[0];
  374. const processDefinitionId = first && first.processDefinition ? first.processDefinition.id : "";
  375. if (!processDefinitionId) {
  376. throw new Error(`未匹配到审批流程(${businessKey}),请联系管理员配置流程定义`);
  377. }
  378. // 两段式第 2 步:发起流程实例
  379. const params = {
  380. businessId: String(businessId),
  381. businessKey,
  382. formCreateUserId: variables.userId || "",
  383. processDefinitionId,
  384. variables,
  385. };
  386. console.log(
  387. "[考勤审批] POST /bpm/process-instance/create:",
  388. type || variables.businessType || "-",
  389. JSON.stringify(params)
  390. );
  391. return withTimeout(
  392. processInstanceCreateAPI(params, false),
  393. REQUEST_TIMEOUT,
  394. `发起审批超时(${businessKey}),请到“我的申请”确认是否已提交`
  395. );
  396. }