attendanceApply.js 14 KB

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