attendance.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. // HR 考勤工具集
  2. // 由 kd-aiot-frontend-hr-H5 的 src/utils/attendance-time.ts + src/utils/location.ts
  3. // 以及 src/api/attendance.ts 中的纯函数部分迁移而来(Vue2 / JS 写法)
  4. // ============ 枚举 ============
  5. export const HOLIDAY_DAY_TYPE = {
  6. LEGAL_HOLIDAY: "LEGAL_HOLIDAY",
  7. COMPENSATORY_WORKDAY: "COMPENSATORY_WORKDAY",
  8. COMPENSATORY_RESTDAY: "COMPENSATORY_RESTDAY",
  9. };
  10. export const HOLIDAY_SOURCE_TYPE = {
  11. SYSTEM: "SYSTEM",
  12. EXTERNAL: "EXTERNAL",
  13. MANUAL: "MANUAL",
  14. };
  15. const WEEKDAY_KEYWORDS = {
  16. 周一: 1, 周二: 2, 周三: 3, 周四: 4, 周五: 5, 周六: 6, 周日: 7,
  17. 星期一: 1, 星期二: 2, 星期三: 3, 星期四: 4, 星期五: 5, 星期六: 6, 星期日: 7,
  18. Monday: 1, Tuesday: 2, Wednesday: 3, Thursday: 4,
  19. Friday: 5, Saturday: 6, Sunday: 7,
  20. Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7,
  21. };
  22. const WEEKDAY_LABELS = {
  23. 1: "周一", 2: "周二", 3: "周三", 4: "周四", 5: "周五", 6: "周六", 7: "周日",
  24. };
  25. const pad2 = (n) => String(n).padStart(2, "0");
  26. // ============ 日期格式化 ============
  27. /** yyyy-MM */
  28. export function formatMonth(date) {
  29. const d = date instanceof Date ? date : new Date(date);
  30. return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
  31. }
  32. /**
  33. * 健壮的 yyyy-MM-dd。
  34. * 兼容 Date / 时间戳 / "yyyy-MM-dd" / "yyyy-MM-dd HH:mm:ss" / ISO 串,
  35. * 非法值返回空字符串(调用方据此跳过该条数据)。
  36. */
  37. export function formatDateKey(value) {
  38. if (value === null || value === undefined || value === "") return "";
  39. let d;
  40. if (value instanceof Date) {
  41. d = value;
  42. } else if (typeof value === "number") {
  43. d = new Date(value);
  44. } else {
  45. const raw = String(value);
  46. if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
  47. d = new Date(/[T ]/.test(raw) ? raw : raw.replace(/-/g, "/"));
  48. }
  49. if (!d || isNaN(d.getTime())) return "";
  50. return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
  51. }
  52. /** 解析打卡时间:支持 Date / 时间戳 / "HH:mm" / "HH:mm:ss" / 日期串 */
  53. export function parsePunchTime(value) {
  54. if (!value) return null;
  55. if (value instanceof Date) return isNaN(value.getTime()) ? null : value;
  56. if (typeof value === "number") {
  57. const d = new Date(value);
  58. return isNaN(d.getTime()) ? null : d;
  59. }
  60. const raw = String(value);
  61. if (/[T ]/.test(raw)) {
  62. const d = new Date(raw);
  63. return isNaN(d.getTime()) ? null : d;
  64. }
  65. const m = raw.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
  66. if (m) {
  67. const now = new Date();
  68. return new Date(
  69. now.getFullYear(), now.getMonth(), now.getDate(),
  70. Number(m[1]), Number(m[2]), Number(m[3] || 0)
  71. );
  72. }
  73. const d = new Date(raw.replace(/-/g, "/"));
  74. return isNaN(d.getTime()) ? null : d;
  75. }
  76. /** 解析成 yyyy-MM-dd 键;失败返回 null */
  77. export function punchDateKey(value) {
  78. const d = parsePunchTime(value);
  79. if (!d) return null;
  80. return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
  81. }
  82. /** end - start 分钟数,负数截断为 0 */
  83. export function minutesBetween(start, end) {
  84. const s = parsePunchTime(start);
  85. const e = parsePunchTime(end);
  86. if (!s || !e) return 0;
  87. const diff = Math.floor((e.getTime() - s.getTime()) / 60000);
  88. return diff > 0 ? diff : 0;
  89. }
  90. /**
  91. * 本地补偿迟到 / 早退 / 工时(后端 daily 接口常不下发达这仨事实字段)。
  92. * 跨日补卡(actualOut 日期 ≠ 当天)按当天 23:59 截断工时,且不算早退。
  93. */
  94. export function computeDailyMinutes(params) {
  95. const { actualIn, actualOut, scheduledStart, scheduledEnd, dateStr } = params || {};
  96. let lateMinutes = 0;
  97. let earlyMinutes = 0;
  98. let actualWorkMinutes = 0;
  99. let isCrossDayRepair = false;
  100. const outDateKey = punchDateKey(actualOut);
  101. if (actualOut && dateStr && outDateKey && outDateKey !== dateStr) {
  102. isCrossDayRepair = true;
  103. }
  104. if (actualIn && scheduledStart) {
  105. const inDate = parsePunchTime(actualIn);
  106. if (inDate) {
  107. const parts = String(scheduledStart).split(":");
  108. const scheduled = new Date(
  109. inDate.getFullYear(), inDate.getMonth(), inDate.getDate(),
  110. Number(parts[0]), Number(parts[1])
  111. );
  112. const diff = Math.floor((inDate.getTime() - scheduled.getTime()) / 60000);
  113. if (diff > 0) lateMinutes = diff;
  114. }
  115. }
  116. if (actualOut && scheduledEnd && !isCrossDayRepair) {
  117. const outDate = parsePunchTime(actualOut);
  118. if (outDate) {
  119. const parts = String(scheduledEnd).split(":");
  120. const scheduled = new Date(
  121. outDate.getFullYear(), outDate.getMonth(), outDate.getDate(),
  122. Number(parts[0]), Number(parts[1])
  123. );
  124. const diff = Math.floor((scheduled.getTime() - outDate.getTime()) / 60000);
  125. if (isFinite(diff) && diff > 0) earlyMinutes = diff;
  126. }
  127. }
  128. if (actualIn && actualOut) {
  129. const s = parsePunchTime(actualIn);
  130. const e = parsePunchTime(actualOut);
  131. if (s && e && e > s) {
  132. if (isCrossDayRepair) {
  133. const dayEnd = new Date(s.getFullYear(), s.getMonth(), s.getDate(), 23, 59, 59);
  134. actualWorkMinutes = Math.floor((dayEnd.getTime() - s.getTime()) / 60000);
  135. } else {
  136. actualWorkMinutes = Math.floor((e.getTime() - s.getTime()) / 60000);
  137. }
  138. }
  139. }
  140. return { lateMinutes, earlyMinutes, actualWorkMinutes, isCrossDayRepair };
  141. }
  142. /** 跨日补卡的下班时间显示成 "次日 HH:mm" */
  143. export function formatPunchTimeWithDate(value, baseDateStr) {
  144. if (!value) return "";
  145. const t = punchDateKey(value);
  146. const base = baseDateStr || "";
  147. const m = String(value).match(/(\d{1,2}:\d{2})/);
  148. const hm = m ? m[1] : String(value);
  149. if (!t || !base || t === base) return hm;
  150. return `次日 ${hm}`;
  151. }
  152. // ============ 打卡类型(上班卡 / 下班卡)判定 ============
  153. /**
  154. * 时间串 → 当天分钟数(0~1439)。
  155. * 兼容 "HH:mm" / "HH:mm:ss" / "yyyy-MM-dd HH:mm:ss" / ISO(取第一段 HH:mm);
  156. * 非法值返回 null。
  157. */
  158. export function minutesOfDay(value) {
  159. if (!value) return null;
  160. const m = String(value).match(/(\d{1,2}):(\d{2})/);
  161. if (!m) return null;
  162. const h = Number(m[1]);
  163. const mi = Number(m[2]);
  164. if (!isFinite(h) || !isFinite(mi) || h > 23 || mi > 59) return null;
  165. return h * 60 + mi;
  166. }
  167. /** 分钟数 → "HH:mm" */
  168. export function formatMinutesOfDay(minutes) {
  169. if (minutes === null || minutes === undefined || !isFinite(minutes)) return "";
  170. const m = ((Math.round(minutes) % 1440) + 1440) % 1440;
  171. return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
  172. }
  173. /** 拿不到班次时间时的默认「上午结束」分界 */
  174. export const DEFAULT_HALF_DAY_END_MINUTES = 12 * 60;
  175. /**
  176. * 「上班卡」的有效截止分界(分钟数)。
  177. * 过了这个点还没打上班卡 → 视为忘打卡,之后点击打卡按「下班卡」处理(上班卡需走补卡流程)。
  178. * 分界规则(2026-10-23 用户口径):规则里配置了上午下班时间用配置值,否则固定 12:00。
  179. * shiftStart / shiftEnd 参数保留兼容旧调用,不再参与计算。
  180. */
  181. export function resolvePunchInDeadline(shiftStart, shiftEnd, halfDayEnd) {
  182. const explicit = minutesOfDay(halfDayEnd);
  183. if (explicit !== null) return explicit;
  184. return DEFAULT_HALF_DAY_END_MINUTES;
  185. }
  186. /**
  187. * 判定本次该打「上班卡」还是「下班卡」。
  188. *
  189. * - 上下班都打过 → "DONE"
  190. * - 已打上班未打下班 → "OUT"
  191. * - 都没打:已过上班卡截止分界(规则配置的上午下班时间,未配置则 12:00)→ "OUT",否则 "IN"
  192. * (2026-10-23 用户口径:12 点之前是上班打卡,之后是下班打卡,不再按班次中点算)
  193. *
  194. * @param {object} params
  195. * @param {string} [params.actualIn] 已记录的上班打卡时间
  196. * @param {string} [params.actualOut] 已记录的下班打卡时间
  197. * @param {string} [params.shiftStart] 班次上班时间(保留兼容,不参与分界计算)
  198. * @param {string} [params.shiftEnd] 班次下班时间(保留兼容,不参与分界计算)
  199. * @param {string} [params.halfDayEnd] 规则里配置的上午下班时间,优先级最高
  200. * @param {Date} [params.now] 当前时间,默认 new Date()
  201. * @returns {"IN"|"OUT"|"DONE"}
  202. */
  203. export function resolvePunchType(params = {}) {
  204. const { actualIn, actualOut } = params;
  205. if (actualIn && actualOut) return "DONE";
  206. if (actualIn) return "OUT";
  207. const now = params.now instanceof Date ? params.now : new Date();
  208. const nowMinutes = now.getHours() * 60 + now.getMinutes();
  209. const explicit = minutesOfDay(params.halfDayEnd);
  210. return nowMinutes >= (explicit !== null ? explicit : DEFAULT_HALF_DAY_END_MINUTES)
  211. ? "OUT"
  212. : "IN";
  213. }
  214. /**
  215. * 判断当前是否属于「提前打下班卡」(已打上班卡、未打下班卡、还没到班次下班时间)。
  216. * 这种情况不拦截打卡,但要提示「记为早退」并二次确认。
  217. *
  218. * @param {object} params
  219. * @param {string} [params.actualIn] 已记录的上班打卡时间(必须已打上班卡才可能早退)
  220. * @param {string} [params.actualOut] 已记录的下班打卡时间
  221. * @param {string} [params.shiftEnd] 班次下班时间(HH:mm 或含时间的任意串)
  222. * @param {Date} [params.now] 当前时间,默认 new Date()
  223. * @returns {{early: boolean, endTime: string}} early 是否提前打下班卡;endTime 班次下班时间(HH:mm)
  224. */
  225. export function resolveEarlyOut(params = {}) {
  226. const { actualIn, actualOut, shiftEnd } = params;
  227. if (!actualIn || actualOut) return { early: false, endTime: "" };
  228. const endMinutes = minutesOfDay(shiftEnd);
  229. if (endMinutes === null) return { early: false, endTime: "" };
  230. const d = params.now instanceof Date ? params.now : new Date();
  231. const nowMinutes = d.getHours() * 60 + d.getMinutes();
  232. return {
  233. early: nowMinutes < endMinutes,
  234. endTime: formatMinutesOfDay(endMinutes),
  235. };
  236. }
  237. // ============ 周班次表解析 ============
  238. const SHIFT_RANGE_RE = /(?:(.+?)\s*)?[((]\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*[-~到至]\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*[))]/;
  239. const TIME_RANGE_ONLY_RE = /(\d{1,2}:\d{2}(?::\d{2})?)\s*[-~到至]\s*(\d{1,2}:\d{2}(?::\d{2})?)/;
  240. function buildRestStub(i) {
  241. return {
  242. weekday: i,
  243. weekdayLabel: WEEKDAY_LABELS[i],
  244. isRestDay: true,
  245. shiftName: "",
  246. segments: [],
  247. };
  248. }
  249. /**
  250. * 解析后端 shiftTimeSummary(如 "周一:行政班(08:30-17:30);周六:休息")
  251. * 为 7 项(周一→周日)班次数组。
  252. */
  253. export function parseShiftWeeklySummary(summary) {
  254. if (!summary || typeof summary !== "string") {
  255. return [1, 2, 3, 4, 5, 6, 7].map(buildRestStub);
  256. }
  257. const byWeekday = {};
  258. summary
  259. .split(/[;;]/)
  260. .map((s) => s.trim())
  261. .filter(Boolean)
  262. .forEach((part) => {
  263. const m = part.match(/^([^::]+)[::]\s*(.*)$/);
  264. if (!m) return;
  265. const weekday = WEEKDAY_KEYWORDS[m[1].trim()];
  266. if (!weekday) return;
  267. const content = m[2].trim();
  268. if (/^休(息|息日)?$/.test(content)) {
  269. byWeekday[weekday] = buildRestStub(weekday);
  270. return;
  271. }
  272. const withName = content.match(SHIFT_RANGE_RE);
  273. if (withName) {
  274. byWeekday[weekday] = {
  275. weekday,
  276. weekdayLabel: WEEKDAY_LABELS[weekday],
  277. isRestDay: false,
  278. shiftName: (withName[1] || "").trim(),
  279. segments: [{ start: withName[2].slice(0, 5), end: withName[3].slice(0, 5), raw: content }],
  280. };
  281. return;
  282. }
  283. const bare = content.match(TIME_RANGE_ONLY_RE);
  284. if (bare) {
  285. byWeekday[weekday] = {
  286. weekday,
  287. weekdayLabel: WEEKDAY_LABELS[weekday],
  288. isRestDay: false,
  289. shiftName: "",
  290. segments: [{ start: bare[1].slice(0, 5), end: bare[2].slice(0, 5), raw: content }],
  291. };
  292. }
  293. });
  294. const result = [];
  295. for (let i = 1; i <= 7; i++) result.push(byWeekday[i] || buildRestStub(i));
  296. return result;
  297. }
  298. /** 按日期取周班次表中的那一天(周日 → 索引 6) */
  299. export function getShiftForDate(dateStr, schedule) {
  300. if (!dateStr || !Array.isArray(schedule) || schedule.length === 0) return null;
  301. const d = new Date(String(dateStr).replace(/-/g, "/"));
  302. if (isNaN(d.getTime())) return null;
  303. const iso = d.getDay() === 0 ? 7 : d.getDay();
  304. return schedule[iso - 1] || null;
  305. }
  306. /** 后端 holidayDates → Map<yyyy-MM-dd, HolidayInfo> */
  307. export function buildHolidayMap(holidayDates) {
  308. const map = new Map();
  309. if (!Array.isArray(holidayDates)) return map;
  310. holidayDates.forEach((item) => {
  311. if (!item || item.calendarDate === null || item.calendarDate === undefined) return;
  312. const dateStr = formatDateKey(item.calendarDate);
  313. if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return;
  314. map.set(dateStr, {
  315. calendarDate: dateStr,
  316. changeReason: item.changeReason || "",
  317. dayType: item.dayType || "",
  318. sourceType: item.sourceType || "",
  319. });
  320. });
  321. return map;
  322. }
  323. export function getHolidayInfo(dateStr, holidayMap) {
  324. if (!dateStr || !holidayMap || typeof holidayMap.get !== "function") return null;
  325. return holidayMap.get(dateStr) || null;
  326. }
  327. /** 综合节假日 / 调休 + 周班次表判断是否休息日 */
  328. export function isRestDay(dateStr, schedule, holidayMap) {
  329. if (dateStr && holidayMap && typeof holidayMap.get === "function") {
  330. const holiday = holidayMap.get(dateStr);
  331. if (holiday) {
  332. if (holiday.dayType === HOLIDAY_DAY_TYPE.COMPENSATORY_WORKDAY) return false;
  333. if (
  334. holiday.dayType === HOLIDAY_DAY_TYPE.LEGAL_HOLIDAY ||
  335. holiday.dayType === HOLIDAY_DAY_TYPE.COMPENSATORY_RESTDAY
  336. ) {
  337. return true;
  338. }
  339. }
  340. }
  341. const info = getShiftForDate(dateStr, schedule);
  342. return info ? Boolean(info.isRestDay) : false;
  343. }
  344. function parseShiftSummary(summary) {
  345. if (!summary || typeof summary !== "string") return [];
  346. const segments = [];
  347. summary
  348. .split(/[;;]/)
  349. .map((s) => s.trim())
  350. .filter(Boolean)
  351. .forEach((part) => {
  352. let m = part.match(/\((\d{1,2}:\d{2}(?::\d{2})?)\s*[-~]\s*(\d{1,2}:\d{2}(?::\d{2})?)\)/);
  353. if (!m) m = part.match(/(\d{1,2}:\d{2}(?::\d{2})?)\s*[-~到至]\s*(\d{1,2}:\d{2}(?::\d{2})?)/);
  354. if (m) segments.push({ start: m[1].slice(0, 5), end: m[2].slice(0, 5), raw: part });
  355. });
  356. return segments;
  357. }
  358. /**
  359. * 归一化打卡规则:补出 shiftSegments / weeklySchedule / shiftStartTime /
  360. * shiftEndTime / holidayMap,供页面直接使用。
  361. */
  362. export function normalizeAttendanceRule(rule) {
  363. if (!rule) return null;
  364. const segments = parseShiftSummary(rule.shiftTimeSummary);
  365. const weeklySchedule = parseShiftWeeklySummary(rule.shiftTimeSummary);
  366. const holidayMap = buildHolidayMap(rule.holidayDates);
  367. const workDay = weeklySchedule.find((d) => !d.isRestDay && d.segments.length > 0);
  368. let shiftStartTime = "";
  369. let shiftEndTime = "";
  370. if (workDay) {
  371. const segs = workDay.segments;
  372. shiftStartTime = segs[0].start;
  373. shiftEndTime = segs[segs.length - 1].end;
  374. } else if (segments.length) {
  375. shiftStartTime = segments[0].start;
  376. shiftEndTime = segments[segments.length - 1].end;
  377. }
  378. return Object.assign({}, rule, {
  379. shiftSegments: segments,
  380. weeklySchedule,
  381. shiftStartTime,
  382. shiftEndTime,
  383. holidayMap,
  384. });
  385. }
  386. // ============ 定位 ============
  387. export function getLocation(options) {
  388. const timeout = (options && options.timeout) || 10000;
  389. // #ifdef H5
  390. if (typeof navigator !== "undefined" && navigator.geolocation) {
  391. return new Promise((resolve, reject) => {
  392. navigator.geolocation.getCurrentPosition(
  393. (pos) =>
  394. resolve({
  395. latitude: pos.coords.latitude,
  396. longitude: pos.coords.longitude,
  397. accuracy: pos.coords.accuracy,
  398. }),
  399. (err) => {
  400. const msg =
  401. err && err.code === 1
  402. ? "定位权限被拒绝,请在浏览器中允许定位后重试"
  403. : "获取位置失败,请稍后重试";
  404. reject(new Error(msg));
  405. },
  406. { enableHighAccuracy: true, timeout, maximumAge: 0 }
  407. );
  408. });
  409. }
  410. return Promise.reject(new Error("当前环境不支持定位"));
  411. // #endif
  412. // #ifdef MP-WEIXIN || APP-PLUS
  413. return new Promise((resolve, reject) => {
  414. uni.getLocation({
  415. type: "gcj02",
  416. geocode: false,
  417. success: (res) =>
  418. resolve({
  419. latitude: res.latitude,
  420. longitude: res.longitude,
  421. accuracy: res.accuracy,
  422. }),
  423. fail: (err) => reject(new Error((err && err.errMsg) || "获取位置失败")),
  424. });
  425. });
  426. // #endif
  427. }