import { get, postJ } from "@/utils/request"; import { normalizeAttendanceRule } from "@/utils/hr/attendance"; import Vue from "vue"; // 考勤状态字典(后端返回英文枚举) const STATUS_LABELS = { NORMAL: "正常", LATE: "迟到", EARLY: "早退", ABSENT: "缺卡", MISSING: "缺卡", LEAVE: "请假", OVERTIME: "加班", TRIP: "出差", FIELD: "外勤", REST: "休息", HOLIDAY: "节假日", ADJUST: "调休", }; export function translateStatus(statusValue) { if (!statusValue) return ""; const values = Array.isArray(statusValue) ? statusValue : [statusValue]; return values .map((v) => STATUS_LABELS[v] || String(v)) .filter(Boolean) .join(" / "); } const pad2 = (n) => String(n).padStart(2, "0"); /** Date → `YYYY-MM-DD HH:mm:ss`(本地时区) */ function fromDate(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2( d.getHours() )}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; } /** * 打卡时间归一化:接口要求 `YYYY-MM-DD HH:mm:ss`(本地时区,秒级)。 * 兼容:Date / 时间戳(秒、毫秒)/ ISO 串(带 T、带 Z 或 ±hh:mm)/ 纯日期 / 空值。 * 空值或无法解析时回退为当前时间。 */ export function formatPunchTime(value) { if (value === undefined || value === null || value === "") return fromDate(new Date()); if (value instanceof Date) { return isNaN(value.getTime()) ? fromDate(new Date()) : fromDate(value); } if (typeof value === "number") { // 秒级时间戳(10 位)与毫秒级(13 位)都能识别 const ms = Math.abs(value) < 1e11 ? value * 1000 : value; const d = new Date(ms); return isNaN(d.getTime()) ? fromDate(new Date()) : fromDate(d); } const text = String(value).trim(); if (/^\d{10}$/.test(text)) return fromDate(new Date(Number(text) * 1000)); if (/^\d{13}$/.test(text)) return fromDate(new Date(Number(text))); // 日期 + 时间(T 或空格分隔,秒、毫秒可省) const m = text.match( /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T\s](\d{1,2}):(\d{2})(?::(\d{2}))?/ ); if (m) { // 带时区标识时按绝对时刻解析,换算成本地时间,避免 UTC 直接截断出错时 if (/(?:Z|[+-]\d{2}:?\d{2})$/.test(text)) { const d = new Date(text); if (!isNaN(d.getTime())) return fromDate(d); } return `${m[1]}-${pad2(m[2])}-${pad2(m[3])} ${pad2(m[4])}:${m[5]}:${m[6] || "00"}`; } // 纯日期补零时 const dm = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/); if (dm) return `${dm[1]}-${pad2(dm[2])}-${pad2(dm[3])} 00:00:00`; const fallback = new Date(text); return isNaN(fallback.getTime()) ? fromDate(new Date()) : fromDate(fallback); } /** 转成有限数字;null / undefined / "" / NaN / Infinity 等非法值返回 null */ function toFiniteNumber(value) { if (value === null || value === undefined || value === "") return null; const n = typeof value === "number" ? value : Number(value); return isFinite(n) ? n : null; } /** 经纬度保留 6 位小数,避免浮点长尾 */ function round6(n) { return Math.round(n * 1e6) / 1e6; } /** * 管理端完整考勤模型(指定人 + 日期) * GET /hr/attendance/daily/{userId}/{date} */ export async function queryDailyAttendance(userId, date, loding = true) { if (!userId || !date) return null; const res = await get( Vue.prototype.apiUrl + `/hr/attendance/daily/${userId}/${date}`, {}, loding ); if (res.code == 0) return res.data || null; return null; } /** * 给 Promise 加超时兜底。 * * `utils/request` 基于 uni.request 的 complete 回调,网络异常时内部可能抛错导致 * Promise 永不 settle,await 会一直挂着(既不 resolve 也不 reject), * 调用方的 catch 不触发,页面就停在初始状态。这里强制一个上限。 */ /** * 请求兜底:utils/request 的 complete 回调在非 JSON 响应(网关 HTML / 404)下会抛异常, * 导致 Promise 永不 settle、await 永久挂起(既无报错也无网络后续请求)。 * 所有关键请求都用它包一层,超时即显式失败并给出可读原因。 */ export function withTimeout(promise, ms, message) { return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; reject(new Error(message)); }, ms); promise.then( (value) => { if (settled) return; settled = true; clearTimeout(timer); resolve(value); }, (error) => { if (settled) return; settled = true; clearTimeout(timer); reject(error); } ); }); } function currentUserId() { const userInfo = uni.getStorageSync("userInfo") || {}; const raw = userInfo.raw || {}; const candidates = [userInfo.userId, userInfo.id, raw.userId, raw.id]; return candidates.find((value) => value !== undefined && value !== null && value !== ""); } // 管理端完整模型接口失败过一次(如 ESS 无权限)后不再重试,避免每次加载都等超时 let adminDailyDisabled = false; let adminDailyDisabledReason = ""; // 联调:最近一次管理端单日考勤补充的状态说明(诊断面板展示用) let lastAdminDailyNote = ""; export function getAdminDailyNote() { if (adminDailyDisabled) { return `已被禁用不再重试:${adminDailyDisabledReason}`; } return lastAdminDailyNote || "本次未记录"; } /** * ESS 单日汇总只回统计数字(attendanceStatusList/actualWorkMinutes 等), * 不含 actuallyIn/actualOut 打卡时间 —— 用管理端完整模型补上。 * 失败(无权限/无记录)时静默降级,只展示汇总。 */ async function mergeAdminDaily(summary, date) { if (adminDailyDisabled) { lastAdminDailyNote = `跳过:${adminDailyDisabledReason}`; return summary; } const userId = currentUserId(); if (!userId) { lastAdminDailyNote = "跳过:本地未取到当前用户 id(userInfo.userId/id 均为空)"; return summary; } const url = `/hr/attendance/daily/${userId}/${date}`; try { const detail = await withTimeout( get(Vue.prototype.apiUrl + url, {}, false), 8000, "管理端单日考勤查询超时" ); if (!(detail && detail.code == 0)) { // 接口明确拒绝(如 ESS 账号无管理端权限):本次会话内不再重试 adminDailyDisabled = true; adminDailyDisabledReason = `管理端接口返回 code=${detail ? detail.code : "无响应"}(${url})`; lastAdminDailyNote = `失败:${adminDailyDisabledReason}`; console.log("[考勤] 管理端单日考勤不可用,降级为汇总展示:", lastAdminDailyNote); return summary; } if (!detail.data) { lastAdminDailyNote = `管理端返回 code=0 但 data 为空(当日暂无考勤记录)→ ${url}`; console.log("[考勤]", lastAdminDailyNote); return summary; // 当日暂无考勤记录,下次加载再试 } const d = normalizeDailyAttendance(detail.data) || {}; lastAdminDailyNote = `成功 → ${url},返回 ${JSON.stringify(detail.data)}`; return Object.assign({}, summary, { // 打卡时间与排班以管理端完整模型为准;统计数字仍用 ESS 汇总 actualIn: d.actualIn || null, actualOut: d.actualOut || null, scheduledStart: d.scheduledStart || summary.scheduledStart || null, scheduledEnd: d.scheduledEnd || summary.scheduledEnd || null, }); } catch (error) { // 超时/网络类失败不永久禁用,下次加载再试 lastAdminDailyNote = `请求失败(不禁用,下次重试)→ ${url}:${(error && error.message) || error}`; console.log("[考勤] 管理端单日考勤补充失败(降级为汇总):", lastAdminDailyNote); return summary; } } /** * 员工自助单日考勤 * GET /hr/ess/attendance/dates/{date} * * 注意:code != 0 时抛错而不是静默返回 null —— 之前静默吞掉导致 * 「打卡成功但刷新后又显示未打卡」时完全无从排查。 * 实测后端该接口只回统计数字不含打卡时间,这里自动合并管理端完整模型补 actualIn/actualOut。 */ export async function queryMyDayAttendance(date, loding = true) { if (!date) return null; const res = await withTimeout( get(Vue.prototype.apiUrl + `/hr/ess/attendance/dates/${date}`, {}, loding), 10000, "单日考勤查询超时(10s 无响应)" ); if (!(res && res.code == 0)) { throw new Error( (res && res.message) || `单日考勤查询失败(code=${res ? res.code : "无响应"})` ); } const summary = res.data || null; if (!summary) return null; const merged = await mergeAdminDaily(summary, date); // 联调日志:确认合并后的最终数据 console.log("[考勤] 单日考勤返回:", date, JSON.stringify(merged)); return merged; } /** 状态字段归一化:后端可能回数组,也可能回 "ABSENT,LATE" 这种逗号串,统一拆成数组 */ function toStatusList(value) { if (Array.isArray(value)) return value.filter(Boolean); if (value === undefined || value === null || value === "") return []; return String(value) .split(",") .map((item) => item.trim()) .filter(Boolean); } /** * 单日考勤字段归一化:不同后端版本对实际上/下班时间的字段命名可能不一致, * 按候选顺序兜底取值,避免因字段名差异导致「已打卡却显示未打卡」。 */ export function normalizeDailyAttendance(data) { if (!data || typeof data !== "object") return null; const firstOf = (keys) => { for (const key of keys) { const v = data[key]; if (v !== undefined && v !== null && v !== "") return v; } return null; }; return { actualIn: firstOf(["actualIn", "actualInTime", "inTime", "punchInTime", "clockInTime", "firstPunchTime"]), actualOut: firstOf(["actualOut", "actualOutTime", "outTime", "punchOutTime", "clockOutTime", "lastPunchTime"]), scheduledStart: firstOf(["scheduledStart", "scheduledStartTime", "planStartTime", "shiftStartTime"]), scheduledEnd: firstOf(["scheduledEnd", "scheduledEndTime", "planEndTime", "shiftEndTime"]), lateMinutes: data.lateMinutes || 0, earlyMinutes: data.earlyMinutes || 0, statusList: toStatusList(data.statusList || data.attendanceStatusList), actualWorkMinutes: data.actualWorkMinutes || 0, raw: data, }; } /** * 员工自助月度考勤(月历) * GET /hr/ess/attendance/month?month=yyyy-MM */ export async function queryMonthAttendance(month, loding = true) { const res = await get( Vue.prototype.apiUrl + "/hr/ess/attendance/month", { month }, loding ); if (res.code == 0) return Array.isArray(res.data) ? res.data : []; return []; } /** * 移动端打卡 * POST /hr/ess/punches * * GPS 打卡必须带经纬度:拿不到就直接抛错,避免发出「无坐标」的无效打卡。 * 注意 uni.request 在 application/json 下会 JSON.stringify,值为 undefined 的键会被丢弃, * 所以这里必须显式校验并转成数字。 */ export async function submitPunch(payload = {}) { const method = payload.method || "GPS"; const body = { punchMethod: method, // 接口要求 `YYYY-MM-DD HH:mm:ss`(本地时区),这里统一归一化, // 避免上游传 ISO 串(带 T/Z)导致时间格式不符或时区偏移。 punchTime: formatPunchTime(payload.time), }; if (method === "GPS") { const lat = toFiniteNumber(payload.lat); const lng = toFiniteNumber(payload.lng); if (lat === null || lng === null) { throw new Error("定位失败,未获取到经纬度,请开启定位权限后重试"); } body.latitude = round6(lat); body.longitude = round6(lng); const accuracy = toFiniteNumber(payload.accuracy); if (accuracy !== null) body.accuracy = Math.round(accuracy); if (payload.address) body.address = payload.address; } else if (method === "WIFI") { if (payload.wifiBssid) body.wifiBssid = payload.wifiBssid; } const res = await postJ(Vue.prototype.apiUrl + "/hr/ess/punches", body, true); if (res && res.code == 0) { // 联调期日志:看后端是否真的落库(配合单日考勤返回一起定位「打卡成功但回显不出」) console.log("[考勤] 打卡提交返回:", JSON.stringify(res.data)); return res.data; } return Promise.reject(new Error((res && res.message) || "打卡失败")); } /** * 移动端打卡规则 * GET /hr/ess/attendance/punch-rule */ export async function queryAttendanceRule(loding = true) { try { const res = await get( Vue.prototype.apiUrl + "/hr/ess/attendance/punch-rule", {}, loding ); if (res.code == 0) { const rule = normalizeAttendanceRule(res.data || null); if (!rule || !rule.ruleId) return { rule: null, status: "empty", loaded: true }; if (rule.exemptFromPunch === true) { return { rule, status: "disabled", blockedReason: rule.message || "已配置免打卡", loaded: true }; } if (rule.mobilePunchEnabled === false) { return { rule, status: "disabled", blockedReason: rule.message || "不允许移动端打卡", loaded: true, }; } return { rule, status: "ok", loaded: true }; } return { rule: null, status: "empty", loaded: true }; } catch (error) { return { rule: null, status: "error", error, loaded: false }; } } /** * 班次明细 * GET /hr/attendance/shifts/getShift/{shiftId} */ export async function queryShiftDetail(shiftId, loding = false) { if (!shiftId) return null; const res = await get( Vue.prototype.apiUrl + `/hr/attendance/shifts/getShift/${shiftId}`, {}, loding ); if (res.code == 0) return res.data || null; return null; }