attendanceApply.js 14 KB

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