attendance.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import { get, postJ } from "@/utils/request";
  2. import { normalizeAttendanceRule } from "@/utils/hr/attendance";
  3. import Vue from "vue";
  4. // 考勤状态字典(后端返回英文枚举)
  5. const STATUS_LABELS = {
  6. NORMAL: "正常",
  7. LATE: "迟到",
  8. EARLY: "早退",
  9. ABSENT: "缺卡",
  10. MISSING: "缺卡",
  11. LEAVE: "请假",
  12. OVERTIME: "加班",
  13. TRIP: "出差",
  14. FIELD: "外勤",
  15. REST: "休息",
  16. HOLIDAY: "节假日",
  17. ADJUST: "调休",
  18. };
  19. export function translateStatus(statusValue) {
  20. if (!statusValue) return "";
  21. const values = Array.isArray(statusValue) ? statusValue : [statusValue];
  22. return values
  23. .map((v) => STATUS_LABELS[v] || String(v))
  24. .filter(Boolean)
  25. .join(" / ");
  26. }
  27. const pad2 = (n) => String(n).padStart(2, "0");
  28. /** Date → `YYYY-MM-DD HH:mm:ss`(本地时区) */
  29. function fromDate(d) {
  30. return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(
  31. d.getHours()
  32. )}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
  33. }
  34. /**
  35. * 打卡时间归一化:接口要求 `YYYY-MM-DD HH:mm:ss`(本地时区,秒级)。
  36. * 兼容:Date / 时间戳(秒、毫秒)/ ISO 串(带 T、带 Z 或 ±hh:mm)/ 纯日期 / 空值。
  37. * 空值或无法解析时回退为当前时间。
  38. */
  39. export function formatPunchTime(value) {
  40. if (value === undefined || value === null || value === "") return fromDate(new Date());
  41. if (value instanceof Date) {
  42. return isNaN(value.getTime()) ? fromDate(new Date()) : fromDate(value);
  43. }
  44. if (typeof value === "number") {
  45. // 秒级时间戳(10 位)与毫秒级(13 位)都能识别
  46. const ms = Math.abs(value) < 1e11 ? value * 1000 : value;
  47. const d = new Date(ms);
  48. return isNaN(d.getTime()) ? fromDate(new Date()) : fromDate(d);
  49. }
  50. const text = String(value).trim();
  51. if (/^\d{10}$/.test(text)) return fromDate(new Date(Number(text) * 1000));
  52. if (/^\d{13}$/.test(text)) return fromDate(new Date(Number(text)));
  53. // 日期 + 时间(T 或空格分隔,秒、毫秒可省)
  54. const m = text.match(
  55. /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T\s](\d{1,2}):(\d{2})(?::(\d{2}))?/
  56. );
  57. if (m) {
  58. // 带时区标识时按绝对时刻解析,换算成本地时间,避免 UTC 直接截断出错时
  59. if (/(?:Z|[+-]\d{2}:?\d{2})$/.test(text)) {
  60. const d = new Date(text);
  61. if (!isNaN(d.getTime())) return fromDate(d);
  62. }
  63. return `${m[1]}-${pad2(m[2])}-${pad2(m[3])} ${pad2(m[4])}:${m[5]}:${m[6] || "00"}`;
  64. }
  65. // 纯日期补零时
  66. const dm = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
  67. if (dm) return `${dm[1]}-${pad2(dm[2])}-${pad2(dm[3])} 00:00:00`;
  68. const fallback = new Date(text);
  69. return isNaN(fallback.getTime()) ? fromDate(new Date()) : fromDate(fallback);
  70. }
  71. /** 转成有限数字;null / undefined / "" / NaN / Infinity 等非法值返回 null */
  72. function toFiniteNumber(value) {
  73. if (value === null || value === undefined || value === "") return null;
  74. const n = typeof value === "number" ? value : Number(value);
  75. return isFinite(n) ? n : null;
  76. }
  77. /** 经纬度保留 6 位小数,避免浮点长尾 */
  78. function round6(n) {
  79. return Math.round(n * 1e6) / 1e6;
  80. }
  81. /**
  82. * 管理端完整考勤模型(指定人 + 日期)
  83. * GET /hr/attendance/daily/{userId}/{date}
  84. */
  85. export async function queryDailyAttendance(userId, date, loding = true) {
  86. if (!userId || !date) return null;
  87. const res = await get(
  88. Vue.prototype.apiUrl + `/hr/attendance/daily/${userId}/${date}`,
  89. {},
  90. loding
  91. );
  92. if (res.code == 0) return res.data || null;
  93. return null;
  94. }
  95. /**
  96. * 给 Promise 加超时兜底。
  97. *
  98. * `utils/request` 基于 uni.request 的 complete 回调,网络异常时内部可能抛错导致
  99. * Promise 永不 settle,await 会一直挂着(既不 resolve 也不 reject),
  100. * 调用方的 catch 不触发,页面就停在初始状态。这里强制一个上限。
  101. */
  102. /**
  103. * 请求兜底:utils/request 的 complete 回调在非 JSON 响应(网关 HTML / 404)下会抛异常,
  104. * 导致 Promise 永不 settle、await 永久挂起(既无报错也无网络后续请求)。
  105. * 所有关键请求都用它包一层,超时即显式失败并给出可读原因。
  106. */
  107. export function withTimeout(promise, ms, message) {
  108. return new Promise((resolve, reject) => {
  109. let settled = false;
  110. const timer = setTimeout(() => {
  111. if (settled) return;
  112. settled = true;
  113. reject(new Error(message));
  114. }, ms);
  115. promise.then(
  116. (value) => {
  117. if (settled) return;
  118. settled = true;
  119. clearTimeout(timer);
  120. resolve(value);
  121. },
  122. (error) => {
  123. if (settled) return;
  124. settled = true;
  125. clearTimeout(timer);
  126. reject(error);
  127. }
  128. );
  129. });
  130. }
  131. function currentUserId() {
  132. const userInfo = uni.getStorageSync("userInfo") || {};
  133. const raw = userInfo.raw || {};
  134. const candidates = [userInfo.userId, userInfo.id, raw.userId, raw.id];
  135. return candidates.find((value) => value !== undefined && value !== null && value !== "");
  136. }
  137. // 管理端完整模型接口失败过一次(如 ESS 无权限)后不再重试,避免每次加载都等超时
  138. let adminDailyDisabled = false;
  139. let adminDailyDisabledReason = "";
  140. // 联调:最近一次管理端单日考勤补充的状态说明(诊断面板展示用)
  141. let lastAdminDailyNote = "";
  142. export function getAdminDailyNote() {
  143. if (adminDailyDisabled) {
  144. return `已被禁用不再重试:${adminDailyDisabledReason}`;
  145. }
  146. return lastAdminDailyNote || "本次未记录";
  147. }
  148. /**
  149. * ESS 单日汇总只回统计数字(attendanceStatusList/actualWorkMinutes 等),
  150. * 不含 actuallyIn/actualOut 打卡时间 —— 用管理端完整模型补上。
  151. * 失败(无权限/无记录)时静默降级,只展示汇总。
  152. */
  153. async function mergeAdminDaily(summary, date) {
  154. if (adminDailyDisabled) {
  155. lastAdminDailyNote = `跳过:${adminDailyDisabledReason}`;
  156. return summary;
  157. }
  158. const userId = currentUserId();
  159. if (!userId) {
  160. lastAdminDailyNote = "跳过:本地未取到当前用户 id(userInfo.userId/id 均为空)";
  161. return summary;
  162. }
  163. const url = `/hr/attendance/daily/${userId}/${date}`;
  164. try {
  165. const detail = await withTimeout(
  166. get(Vue.prototype.apiUrl + url, {}, false),
  167. 8000,
  168. "管理端单日考勤查询超时"
  169. );
  170. if (!(detail && detail.code == 0)) {
  171. // 接口明确拒绝(如 ESS 账号无管理端权限):本次会话内不再重试
  172. adminDailyDisabled = true;
  173. adminDailyDisabledReason = `管理端接口返回 code=${detail ? detail.code : "无响应"}(${url})`;
  174. lastAdminDailyNote = `失败:${adminDailyDisabledReason}`;
  175. console.log("[考勤] 管理端单日考勤不可用,降级为汇总展示:", lastAdminDailyNote);
  176. return summary;
  177. }
  178. if (!detail.data) {
  179. lastAdminDailyNote = `管理端返回 code=0 但 data 为空(当日暂无考勤记录)→ ${url}`;
  180. console.log("[考勤]", lastAdminDailyNote);
  181. return summary; // 当日暂无考勤记录,下次加载再试
  182. }
  183. const d = normalizeDailyAttendance(detail.data) || {};
  184. lastAdminDailyNote = `成功 → ${url},返回 ${JSON.stringify(detail.data)}`;
  185. return Object.assign({}, summary, {
  186. // 打卡时间与排班以管理端完整模型为准;统计数字仍用 ESS 汇总
  187. actualIn: d.actualIn || null,
  188. actualOut: d.actualOut || null,
  189. scheduledStart: d.scheduledStart || summary.scheduledStart || null,
  190. scheduledEnd: d.scheduledEnd || summary.scheduledEnd || null,
  191. });
  192. } catch (error) {
  193. // 超时/网络类失败不永久禁用,下次加载再试
  194. lastAdminDailyNote = `请求失败(不禁用,下次重试)→ ${url}:${(error && error.message) || error}`;
  195. console.log("[考勤] 管理端单日考勤补充失败(降级为汇总):", lastAdminDailyNote);
  196. return summary;
  197. }
  198. }
  199. /**
  200. * 员工自助单日考勤
  201. * GET /hr/ess/attendance/dates/{date}
  202. *
  203. * 注意:code != 0 时抛错而不是静默返回 null —— 之前静默吞掉导致
  204. * 「打卡成功但刷新后又显示未打卡」时完全无从排查。
  205. * 实测后端该接口只回统计数字不含打卡时间,这里自动合并管理端完整模型补 actualIn/actualOut。
  206. */
  207. export async function queryMyDayAttendance(date, loding = true) {
  208. if (!date) return null;
  209. const res = await withTimeout(
  210. get(Vue.prototype.apiUrl + `/hr/ess/attendance/dates/${date}`, {}, loding),
  211. 10000,
  212. "单日考勤查询超时(10s 无响应)"
  213. );
  214. if (!(res && res.code == 0)) {
  215. throw new Error(
  216. (res && res.message) || `单日考勤查询失败(code=${res ? res.code : "无响应"})`
  217. );
  218. }
  219. const summary = res.data || null;
  220. if (!summary) return null;
  221. const merged = await mergeAdminDaily(summary, date);
  222. // 联调日志:确认合并后的最终数据
  223. console.log("[考勤] 单日考勤返回:", date, JSON.stringify(merged));
  224. return merged;
  225. }
  226. /** 状态字段归一化:后端可能回数组,也可能回 "ABSENT,LATE" 这种逗号串,统一拆成数组 */
  227. function toStatusList(value) {
  228. if (Array.isArray(value)) return value.filter(Boolean);
  229. if (value === undefined || value === null || value === "") return [];
  230. return String(value)
  231. .split(",")
  232. .map((item) => item.trim())
  233. .filter(Boolean);
  234. }
  235. /**
  236. * 单日考勤字段归一化:不同后端版本对实际上/下班时间的字段命名可能不一致,
  237. * 按候选顺序兜底取值,避免因字段名差异导致「已打卡却显示未打卡」。
  238. */
  239. export function normalizeDailyAttendance(data) {
  240. if (!data || typeof data !== "object") return null;
  241. const firstOf = (keys) => {
  242. for (const key of keys) {
  243. const v = data[key];
  244. if (v !== undefined && v !== null && v !== "") return v;
  245. }
  246. return null;
  247. };
  248. return {
  249. actualIn: firstOf(["actualIn", "actualInTime", "inTime", "punchInTime", "clockInTime", "firstPunchTime"]),
  250. actualOut: firstOf(["actualOut", "actualOutTime", "outTime", "punchOutTime", "clockOutTime", "lastPunchTime"]),
  251. scheduledStart: firstOf(["scheduledStart", "scheduledStartTime", "planStartTime", "shiftStartTime"]),
  252. scheduledEnd: firstOf(["scheduledEnd", "scheduledEndTime", "planEndTime", "shiftEndTime"]),
  253. lateMinutes: data.lateMinutes || 0,
  254. earlyMinutes: data.earlyMinutes || 0,
  255. statusList: toStatusList(data.statusList || data.attendanceStatusList),
  256. actualWorkMinutes: data.actualWorkMinutes || 0,
  257. raw: data,
  258. };
  259. }
  260. /**
  261. * 员工自助月度考勤(月历)
  262. * GET /hr/ess/attendance/month?month=yyyy-MM
  263. */
  264. export async function queryMonthAttendance(month, loding = true) {
  265. const res = await get(
  266. Vue.prototype.apiUrl + "/hr/ess/attendance/month",
  267. { month },
  268. loding
  269. );
  270. if (res.code == 0) return Array.isArray(res.data) ? res.data : [];
  271. return [];
  272. }
  273. /**
  274. * 移动端打卡
  275. * POST /hr/ess/punches
  276. *
  277. * GPS 打卡必须带经纬度:拿不到就直接抛错,避免发出「无坐标」的无效打卡。
  278. * 注意 uni.request 在 application/json 下会 JSON.stringify,值为 undefined 的键会被丢弃,
  279. * 所以这里必须显式校验并转成数字。
  280. */
  281. export async function submitPunch(payload = {}) {
  282. const method = payload.method || "GPS";
  283. const body = {
  284. punchMethod: method,
  285. // 接口要求 `YYYY-MM-DD HH:mm:ss`(本地时区),这里统一归一化,
  286. // 避免上游传 ISO 串(带 T/Z)导致时间格式不符或时区偏移。
  287. punchTime: formatPunchTime(payload.time),
  288. };
  289. if (method === "GPS") {
  290. const lat = toFiniteNumber(payload.lat);
  291. const lng = toFiniteNumber(payload.lng);
  292. if (lat === null || lng === null) {
  293. throw new Error("定位失败,未获取到经纬度,请开启定位权限后重试");
  294. }
  295. body.latitude = round6(lat);
  296. body.longitude = round6(lng);
  297. const accuracy = toFiniteNumber(payload.accuracy);
  298. if (accuracy !== null) body.accuracy = Math.round(accuracy);
  299. if (payload.address) body.address = payload.address;
  300. } else if (method === "WIFI") {
  301. if (payload.wifiBssid) body.wifiBssid = payload.wifiBssid;
  302. }
  303. const res = await postJ(Vue.prototype.apiUrl + "/hr/ess/punches", body, true);
  304. if (res && res.code == 0) {
  305. // 联调期日志:看后端是否真的落库(配合单日考勤返回一起定位「打卡成功但回显不出」)
  306. console.log("[考勤] 打卡提交返回:", JSON.stringify(res.data));
  307. return res.data;
  308. }
  309. return Promise.reject(new Error((res && res.message) || "打卡失败"));
  310. }
  311. /**
  312. * 移动端打卡规则
  313. * GET /hr/ess/attendance/punch-rule
  314. */
  315. export async function queryAttendanceRule(loding = true) {
  316. try {
  317. const res = await get(
  318. Vue.prototype.apiUrl + "/hr/ess/attendance/punch-rule",
  319. {},
  320. loding
  321. );
  322. if (res.code == 0) {
  323. const rule = normalizeAttendanceRule(res.data || null);
  324. if (!rule || !rule.ruleId) return { rule: null, status: "empty", loaded: true };
  325. if (rule.exemptFromPunch === true) {
  326. return { rule, status: "disabled", blockedReason: rule.message || "已配置免打卡", loaded: true };
  327. }
  328. if (rule.mobilePunchEnabled === false) {
  329. return {
  330. rule,
  331. status: "disabled",
  332. blockedReason: rule.message || "不允许移动端打卡",
  333. loaded: true,
  334. };
  335. }
  336. return { rule, status: "ok", loaded: true };
  337. }
  338. return { rule: null, status: "empty", loaded: true };
  339. } catch (error) {
  340. return { rule: null, status: "error", error, loaded: false };
  341. }
  342. }
  343. /**
  344. * 班次明细
  345. * GET /hr/attendance/shifts/getShift/{shiftId}
  346. */
  347. export async function queryShiftDetail(shiftId, loding = false) {
  348. if (!shiftId) return null;
  349. const res = await get(
  350. Vue.prototype.apiUrl + `/hr/attendance/shifts/getShift/${shiftId}`,
  351. {},
  352. loding
  353. );
  354. if (res.code == 0) return res.data || null;
  355. return null;
  356. }